From 041043257b548e15a55996909c304f5bfa98f243 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 8 Jun 2026 18:09:24 +0530 Subject: [PATCH 01/40] Implemented path-based liveobjects public API for PathObject and Instance class --- .../java/io/ably/lib/object/ObjectType.java | 13 + .../object/instance/LiveObjectInstance.java | 193 ++++++++++++ .../object/instance/types/BinaryInstance.java | 23 ++ .../instance/types/BooleanInstance.java | 23 ++ .../instance/types/JsonArrayInstance.java | 24 ++ .../instance/types/JsonObjectInstance.java | 24 ++ .../instance/types/LiveCounterInstance.java | 72 +++++ .../instance/types/LiveMapInstance.java | 105 +++++++ .../object/instance/types/NumberInstance.java | 23 ++ .../object/instance/types/StringInstance.java | 23 ++ .../io/ably/lib/object/path/PathObject.java | 294 ++++++++++++++++++ .../object/path/types/BinaryPathObject.java | 27 ++ .../object/path/types/BooleanPathObject.java | 27 ++ .../path/types/JsonArrayPathObject.java | 28 ++ .../path/types/JsonObjectPathObject.java | 28 ++ .../path/types/LiveCounterPathObject.java | 86 +++++ .../object/path/types/LiveMapPathObject.java | 125 ++++++++ .../object/path/types/NumberPathObject.java | 27 ++ .../object/path/types/StringPathObject.java | 27 ++ 19 files changed, 1192 insertions(+) create mode 100644 lib/src/main/java/io/ably/lib/object/ObjectType.java create mode 100644 lib/src/main/java/io/ably/lib/object/instance/LiveObjectInstance.java create mode 100644 lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java create mode 100644 lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java create mode 100644 lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java create mode 100644 lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java create mode 100644 lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java create mode 100644 lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java create mode 100644 lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java create mode 100644 lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java create mode 100644 lib/src/main/java/io/ably/lib/object/path/PathObject.java create mode 100644 lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java create mode 100644 lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java create mode 100644 lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java create mode 100644 lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java create mode 100644 lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java create mode 100644 lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java create mode 100644 lib/src/main/java/io/ably/lib/object/path/types/NumberPathObject.java create mode 100644 lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java diff --git a/lib/src/main/java/io/ably/lib/object/ObjectType.java b/lib/src/main/java/io/ably/lib/object/ObjectType.java new file mode 100644 index 000000000..bef18ae95 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/ObjectType.java @@ -0,0 +1,13 @@ +package io.ably.lib.object; + +public enum ObjectType { + STRING, + NUMBER, + BOOLEAN, + BINARY, + JSON_OBJECT, + JSON_ARRAY, + LIVE_MAP, + LIVE_COUNTER, + UNKNOWN, +} diff --git a/lib/src/main/java/io/ably/lib/object/instance/LiveObjectInstance.java b/lib/src/main/java/io/ably/lib/object/instance/LiveObjectInstance.java new file mode 100644 index 000000000..f5bbfbb90 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/LiveObjectInstance.java @@ -0,0 +1,193 @@ +package io.ably.lib.object.instance; + +import com.google.gson.JsonElement; +import io.ably.lib.object.ObjectType; +import io.ably.lib.object.instance.types.BinaryInstance; +import io.ably.lib.object.instance.types.BooleanInstance; +import io.ably.lib.object.instance.types.JsonArrayInstance; +import io.ably.lib.object.instance.types.JsonObjectInstance; +import io.ably.lib.object.instance.types.LiveCounterInstance; +import io.ably.lib.object.instance.types.LiveMapInstance; +import io.ably.lib.object.instance.types.NumberInstance; +import io.ably.lib.object.instance.types.StringInstance; +import io.ably.lib.objects.ObjectsSubscription; +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A direct-reference view of a single LiveObject (a {@code LiveMap} or {@code LiveCounter}) + * or a primitive value. Unlike {@code PathObject}, which resolves a path lazily against + * the LiveObjects graph at every call, an {@code Instance} is bound to a specific + * underlying value identified by its object id (for live objects) and dereferenced in + * O(1). + * + *

Java exposes type-specific sub-types ({@link LiveMapInstance}, + * {@link LiveCounterInstance}, and the primitive {@code *Instance} types). Use the + * {@code as*} helpers to obtain a sub-type wrapper without performing type validation. + * + *

Spec: RTINS1 + */ +public interface LiveObjectInstance { + + /** + * Returns the {@link ObjectType} of the value wrapped by this instance. Use this + * instead of dedicated {@code isLiveMap}/{@code isLiveCounter}/etc. checks. + * + * @return the wrapped object type + */ + @NotNull ObjectType getType(); + + /** + * Returns the object id of the wrapped LiveObject, or {@code null} when the wrapped + * value is a primitive. Only {@link LiveMapInstance} and {@link LiveCounterInstance} + * ever return a non-null id. + * + *

Spec: RTINS3 + * + * @return the wrapped object's id, or {@code null} for primitive instances + */ + @Nullable String getId(); + + /** + * Returns a JSON-serializable, recursively compacted snapshot of the wrapped value. + * Behaves identically to {@code PathObject#compactJson} except that it operates on + * the wrapped value directly instead of resolving a path. An {@code Instance} is + * always bound to a resolved value, so this always returns a non-null result; + * failures of the access API preconditions are signalled via {@code AblyException}. + * + *

Spec: RTINS11 + * + * @return the compacted JSON snapshot + */ + @NotNull JsonElement compactJson(); + + /** + * Subscribes a listener for updates on the underlying LiveObject. The listener is + * invoked whenever the wrapped object is changed by a local or remote operation. + * + *

Subscribe is not supported on primitive instances; implementations may throw + * when called on {@link NumberInstance}, {@link StringInstance}, + * {@link BooleanInstance}, {@link BinaryInstance}, {@link JsonObjectInstance} or + * {@link JsonArrayInstance}. + * + *

Spec: RTINS16 + * + * @param listener the listener to invoke on updates + * @return a subscription handle that can be used to unsubscribe this listener + */ + @NonBlocking + @NotNull ObjectsSubscription subscribe(@NotNull Listener listener); + + /** + * Unsubscribes the specified listener previously registered via + * {@link #subscribe(Listener)}. No-op if the listener is not currently subscribed. + * + * @param listener the listener to remove + */ + @NonBlocking + void unsubscribe(@NotNull Listener listener); + + /** + * Removes all listeners previously registered on this instance. + */ + @NonBlocking + void unsubscribeAll(); + + /** + * Returns this instance wrapped as a {@link LiveMapInstance}. + * + *

Best-effort cast; does not validate the underlying type. Read operations on + * the returned wrapper are always permitted; write/terminal operations will fail + * at call time if the wrapped value is not a {@code LiveMap}. + * + * @return a {@link LiveMapInstance} view of this instance + */ + @NotNull LiveMapInstance asLiveMap(); + + /** + * Returns this instance wrapped as a {@link LiveCounterInstance}. + * Best-effort cast; does not validate the underlying type. + * + * @return a {@link LiveCounterInstance} view of this instance + */ + @NotNull LiveCounterInstance asLiveCounter(); + + /** + * Returns this instance wrapped as a {@link NumberInstance}. + * Best-effort cast; does not validate the underlying type. + * + * @return a {@link NumberInstance} view of this instance + */ + @NotNull NumberInstance asNumber(); + + /** + * Returns this instance wrapped as a {@link StringInstance}. + * Best-effort cast; does not validate the underlying type. + * + * @return a {@link StringInstance} view of this instance + */ + @NotNull StringInstance asString(); + + /** + * Returns this instance wrapped as a {@link BooleanInstance}. + * Best-effort cast; does not validate the underlying type. + * + * @return a {@link BooleanInstance} view of this instance + */ + @NotNull BooleanInstance asBoolean(); + + /** + * Returns this instance wrapped as a {@link BinaryInstance}. + * Best-effort cast; does not validate the underlying type. + * + * @return a {@link BinaryInstance} view of this instance + */ + @NotNull BinaryInstance asBinary(); + + /** + * Returns this instance wrapped as a {@link JsonObjectInstance}. + * Best-effort cast; does not validate the underlying type. + * + * @return a {@link JsonObjectInstance} view of this instance + */ + @NotNull JsonObjectInstance asJsonObject(); + + /** + * Returns this instance wrapped as a {@link JsonArrayInstance}. + * Best-effort cast; does not validate the underlying type. + * + * @return a {@link JsonArrayInstance} view of this instance + */ + @NotNull JsonArrayInstance asJsonArray(); + + /** + * Listener interface for {@link LiveObjectInstance#subscribe(Listener) instance + * subscriptions}. + * + *

Spec: RTINS16a1 + */ + interface Listener { + /** + * Invoked when the wrapped LiveObject is modified. + * + * @param event the event describing the change + */ + void onUpdated(@NotNull SubscriptionEvent event); + } + + /** + * Event delivered to {@link Listener#onUpdated(SubscriptionEvent)} when the wrapped + * LiveObject is updated. + * + *

Spec: RTINS16e + */ + interface SubscriptionEvent { + /** + * Returns the {@link LiveObjectInstance} that was updated. + * + * @return the updated instance + */ + @NotNull LiveObjectInstance getInstance(); + } +} diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java new file mode 100644 index 000000000..d0ef51a26 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java @@ -0,0 +1,23 @@ +package io.ably.lib.object.instance.types; + +import io.ably.lib.object.instance.LiveObjectInstance; +import org.jetbrains.annotations.NotNull; + +/** + * A read-only {@link LiveObjectInstance} bound to a binary primitive value + * (a {@code byte[]}). + * + *

{@link #getId()} always returns {@code null} for primitive instances, and + * subscribe operations are not supported. + */ +public interface BinaryInstance extends LiveObjectInstance { + + /** + * Returns the wrapped binary value. + * + *

Spec: RTINS4 + * + * @return the wrapped bytes + */ + byte @NotNull [] value(); +} diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java new file mode 100644 index 000000000..90c2ec3f8 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java @@ -0,0 +1,23 @@ +package io.ably.lib.object.instance.types; + +import io.ably.lib.object.instance.LiveObjectInstance; +import org.jetbrains.annotations.NotNull; + +/** + * A read-only {@link LiveObjectInstance} bound to a {@code Boolean} primitive value. + * + *

{@link #getId()} always returns {@code null} for primitive instances, and + * subscribe operations are not supported. + */ +public interface BooleanInstance extends LiveObjectInstance { + + /** + * Returns the wrapped boolean. + * + *

Spec: RTINS4 + * + * @return the wrapped boolean value + */ + @NotNull + Boolean value(); +} diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java new file mode 100644 index 000000000..fe5c5b99b --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java @@ -0,0 +1,24 @@ +package io.ably.lib.object.instance.types; + +import com.google.gson.JsonArray; +import io.ably.lib.object.instance.LiveObjectInstance; +import org.jetbrains.annotations.NotNull; + +/** + * A read-only {@link LiveObjectInstance} bound to a {@link JsonArray} primitive value. + * + *

{@link #getId()} always returns {@code null} for primitive instances, and + * subscribe operations are not supported. + */ +public interface JsonArrayInstance extends LiveObjectInstance { + + /** + * Returns the wrapped JSON array. + * + *

Spec: RTINS4 + * + * @return the wrapped JsonArray value + */ + @NotNull + JsonArray value(); +} diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java new file mode 100644 index 000000000..7a8c0bb4e --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java @@ -0,0 +1,24 @@ +package io.ably.lib.object.instance.types; + +import com.google.gson.JsonObject; +import io.ably.lib.object.instance.LiveObjectInstance; +import org.jetbrains.annotations.NotNull; + +/** + * A read-only {@link LiveObjectInstance} bound to a {@link JsonObject} primitive value. + * + *

{@link #getId()} always returns {@code null} for primitive instances, and + * subscribe operations are not supported. + */ +public interface JsonObjectInstance extends LiveObjectInstance { + + /** + * Returns the wrapped JSON object. + * + *

Spec: RTINS4 + * + * @return the wrapped JsonObject value + */ + @NotNull + JsonObject value(); +} diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java new file mode 100644 index 000000000..a05d4f15b --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java @@ -0,0 +1,72 @@ +package io.ably.lib.object.instance.types; + +import io.ably.lib.object.instance.LiveObjectInstance; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; + +/** + * A {@link LiveObjectInstance} bound to a {@code LiveCounter}. Provides type-safe + * access to counter operations such as {@link #value()}, {@link #increment(Number)} + * and {@link #decrement(Number)}. + */ +public interface LiveCounterInstance extends LiveObjectInstance { + + /** + * Returns the current value of the wrapped {@code LiveCounter}. + * + *

Spec: RTINS4 / RTLC5 + * + * @return the counter value + */ + @NotNull + Double value(); + + /** + * Increments the wrapped {@code LiveCounter} by {@code 1}. Equivalent to + * calling {@link #increment(Number)} with {@code 1}. + * + *

Spec: RTINS14a1 (default {@code amount} of {@code 1}) + * + * @return a future that completes when the operation has been acknowledged + */ + @NotNull + CompletableFuture increment(); + + /** + * Increments the wrapped {@code LiveCounter} by {@code amount}. + * + *

Sends a {@code COUNTER_INC} operation to the realtime system; the local state + * is updated when the operation is echoed back. + * + *

Spec: RTINS14 + * + * @param amount the amount to add (may be negative) + * @return a future that completes when the operation has been acknowledged + */ + @NotNull + CompletableFuture increment(@NotNull Number amount); + + /** + * Decrements the wrapped {@code LiveCounter} by {@code 1}. Equivalent to + * calling {@link #decrement(Number)} with {@code 1}. + * + *

Spec: RTINS15a1 (default {@code amount} of {@code 1}) + * + * @return a future that completes when the operation has been acknowledged + */ + @NotNull + CompletableFuture decrement(); + + /** + * Decrements the wrapped {@code LiveCounter} by {@code amount}. Equivalent to + * calling {@link #increment(Number)} with a negated value. + * + *

Spec: RTINS15 + * + * @param amount the amount to subtract (may be negative) + * @return a future that completes when the operation has been acknowledged + */ + @NotNull + CompletableFuture decrement(@NotNull Number amount); +} diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java new file mode 100644 index 000000000..93ef30182 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java @@ -0,0 +1,105 @@ +package io.ably.lib.object.instance.types; + +import io.ably.lib.object.instance.LiveObjectInstance; +import io.ably.lib.objects.type.map.LiveMapValue; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * A {@link LiveObjectInstance} bound to a {@code LiveMap}. Provides type-safe access to + * map-specific operations such as {@link #get(String)}, {@link #entries()} and + * {@link #set(String, LiveMapValue)}. + * + *

Operations are bound to the specific underlying {@code LiveMap}, dereferenced in + * O(1), and do not perform any path resolution. + */ +public interface LiveMapInstance extends LiveObjectInstance { + + /** + * Returns a {@link LiveObjectInstance} wrapping the value at {@code key} of the + * wrapped {@code LiveMap}, or {@code null} when the key is absent / tombstoned. + * + *

Spec: RTINS5 + * + * @param key the key to look up + * @return an instance wrapping the value at {@code key}, or {@code null} + */ + @Nullable + LiveObjectInstance get(@NotNull String key); + + /** + * Returns the entries (key, child {@link LiveObjectInstance}) of the wrapped + * {@code LiveMap}. + * + *

Spec: RTINS6 + * + * @return an unmodifiable iterable of entries + */ + @NotNull + @Unmodifiable + Iterable> entries(); + + /** + * Returns the keys of the wrapped {@code LiveMap}. + * + *

Spec: RTINS7 + * + * @return an unmodifiable iterable of keys + */ + @NotNull + @Unmodifiable + Iterable keys(); + + /** + * Returns the child {@link LiveObjectInstance}s for each value in the wrapped + * {@code LiveMap}. + * + *

Spec: RTINS8 + * + * @return an unmodifiable iterable of value instances + */ + @NotNull + @Unmodifiable + Iterable values(); + + /** + * Returns the number of (non-tombstoned) entries in the wrapped {@code LiveMap}. + * + *

Spec: RTINS9 + * + * @return the map size + */ + @NotNull + Long size(); + + /** + * Sets a key on the wrapped {@code LiveMap} to the provided value. Sends a + * {@code MAP_SET} operation to the realtime system; the local state is updated when + * the operation is echoed back. + * + *

Spec: RTINS12 + * + * @param key the key to set + * @param value the value to associate with {@code key} + * @return a future that completes when the operation has been acknowledged + */ + @NotNull + CompletableFuture set(@NotNull String key, @NotNull LiveMapValue value); + + /** + * Removes a key from the wrapped {@code LiveMap}. Sends a {@code MAP_REMOVE} + * operation to the realtime system; the local state is updated when the operation + * is echoed back. + * + *

Spec: RTINS13 + * + * @param key the key to remove + * @return a future that completes when the operation has been acknowledged + */ + @NotNull + CompletableFuture remove(@NotNull String key); +} diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java new file mode 100644 index 000000000..a778000cf --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java @@ -0,0 +1,23 @@ +package io.ably.lib.object.instance.types; + +import io.ably.lib.object.instance.LiveObjectInstance; +import org.jetbrains.annotations.NotNull; + +/** + * A read-only {@link LiveObjectInstance} bound to a {@code Number} primitive value. + * + *

{@link #getId()} always returns {@code null} for primitive instances, and + * subscribe operations are not supported. + */ +public interface NumberInstance extends LiveObjectInstance { + + /** + * Returns the wrapped number. + * + *

Spec: RTINS4 + * + * @return the wrapped numeric value + */ + @NotNull + Number value(); +} diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java new file mode 100644 index 000000000..9639adfda --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java @@ -0,0 +1,23 @@ +package io.ably.lib.object.instance.types; + +import io.ably.lib.object.instance.LiveObjectInstance; +import org.jetbrains.annotations.NotNull; + +/** + * A read-only {@link LiveObjectInstance} bound to a {@code String} primitive value. + * + *

{@link #getId()} always returns {@code null} for primitive instances, and + * subscribe operations are not supported. + */ +public interface StringInstance extends LiveObjectInstance { + + /** + * Returns the wrapped string. + * + *

Spec: RTINS4 + * + * @return the wrapped string value + */ + @NotNull + String value(); +} diff --git a/lib/src/main/java/io/ably/lib/object/path/PathObject.java b/lib/src/main/java/io/ably/lib/object/path/PathObject.java new file mode 100644 index 000000000..0a2aaefd0 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/path/PathObject.java @@ -0,0 +1,294 @@ +package io.ably.lib.object.path; + +import com.google.gson.JsonElement; +import io.ably.lib.object.ObjectType; +import io.ably.lib.object.instance.LiveObjectInstance; +import io.ably.lib.object.path.types.BinaryPathObject; +import io.ably.lib.object.path.types.BooleanPathObject; +import io.ably.lib.object.path.types.JsonArrayPathObject; +import io.ably.lib.object.path.types.JsonObjectPathObject; +import io.ably.lib.object.path.types.LiveCounterPathObject; +import io.ably.lib.object.path.types.LiveMapPathObject; +import io.ably.lib.object.path.types.NumberPathObject; +import io.ably.lib.object.path.types.StringPathObject; +import io.ably.lib.objects.ObjectsSubscription; +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Provides a path-based, navigational view over the LiveObjects graph rooted at the + * channel's root {@code LiveMap}. A {@code PathObject} encapsulates a path expressed as + * an ordered list of string segments and resolves the path lazily against the current + * client-side state of the graph when read or write operations are invoked. + * + *

Resolution is best-effort: it observes the local object tree at the time the + * operation is called. There is no global transaction primitive, so the value at a given + * path can change between two calls on the same {@code PathObject} (e.g. between + * {@link #exists()} and a subsequent write) as updates from other clients are applied. + * + *

For the strongly-typed flavour of the API in Java, callers normally interact with + * type-specific sub-types ({@link LiveMapPathObject}, {@link LiveCounterPathObject}, and + * the primitive {@code *PathObject} types). Use the {@code as*} helpers to obtain a + * sub-type wrapper without performing type validation. + * + *

Spec: RTPO1, RTPO2 + */ +public interface PathObject { + + /** + * Returns the {@link ObjectType} of the value the resolved at this path currently. + * Use this instead of dedicated {@code isLiveMap}/{@code isLiveCounter}/etc. checks. + * + * @return the resolved object type at this path + */ + @NotNull ObjectType getType(); + + /** + * Returns a dot-delimited string representation of the stored path segments. + * Dot characters inside individual segments are escaped with a backslash, so a + * path with segments {@code ["a", "b.c", "d"]} is represented as {@code "a.b\.c.d"}. + * An empty path (i.e. the root {@code PathObject}) returns the empty string. + * + *

Spec: RTPO4 + * + * @return the dot-delimited path from the root to this position + */ + @NotNull String path(); + + /** + * Returns a new {@code PathObject} whose path is this path with the segments parsed + * from {@code path} appended. The {@code path} argument is a dot-delimited string; + * a backslash-escaped dot ({@code \.}) is treated as a literal dot within a segment. + * + *

This is purely navigational - no resolution against the LiveObjects graph is + * performed by this call. {@code pathObject.at("a.b.c")} is equivalent to + * {@code pathObject.get("a").get("b").get("c")} on a {@link LiveMapPathObject}. + * + *

For primitive {@code *PathObject} sub-types and {@link LiveCounterPathObject}, + * deeper navigation is not meaningful; implementations may throw or return a + * {@code PathObject} that will fail to resolve at read/write time. + * + *

Spec: RTPO6 + * + * @param path dot-delimited path to append to this path + * @return a new {@code PathObject} representing the deeper path + */ + @NotNull PathObject at(@NotNull String path); + + /** + * Resolves this path and returns a {@link LiveObjectInstance} wrapping the underlying + * value if it is a {@code LiveMap} or {@code LiveCounter}. + * + *

Returns {@code null} when the resolved value is a primitive (LiveObjects with + * no object id), when the path does not resolve, or when called on primitive + * {@code *PathObject} sub-types. + * + *

Spec: RTPO8 + * + * @return a {@link LiveObjectInstance} wrapping the resolved live object, or {@code null} + */ + @Nullable LiveObjectInstance instance(); + + /** + * Returns a JSON-serializable, recursively compacted snapshot of the value at this + * path. Behaves like the spec's {@code compact} except that {@code Binary} values + * are base64-encoded and cyclic references are represented as + * {@code { "objectId": ... }} markers, so the result is safe to serialise as JSON. + * + *

Returns {@code null} when the path does not resolve. + * + *

Spec: RTPO14 + * + * @return the compacted JSON snapshot, or {@code null} if the path does not resolve + */ + @Nullable JsonElement compactJson(); + + /** + * Subscribes a listener for path-based update events. The listener is invoked when + * an operation modifies the value at this path. The same path may be subscribed by + * multiple listeners independently. + * + *

Spec: RTPO19 + * + * @param listener the listener to invoke on updates + * @return a subscription handle that can be used to unsubscribe this listener + */ + @NonBlocking + @NotNull ObjectsSubscription subscribe(@NotNull Listener listener); + + /** + * Subscribes a listener for path-based update events using the provided + * {@link SubscriptionOptions}. Options control coverage rules such as the + * {@code depth} of nested updates that trigger the listener. + * + *

Spec: RTPO19 + * + * @param listener the listener to invoke on updates + * @param options optional subscription options, may be {@code null} + * @return a subscription handle that can be used to unsubscribe this listener + */ + @NonBlocking + @NotNull ObjectsSubscription subscribe(@NotNull Listener listener, @Nullable SubscriptionOptions options); + + /** + * Unsubscribes the specified listener previously registered via + * {@link #subscribe(Listener)} or {@link #subscribe(Listener, SubscriptionOptions)}. + * No-op if the listener is not currently subscribed for this path. + * + * @param listener the listener to remove + */ + @NonBlocking + void unsubscribe(@NotNull Listener listener); + + /** + * Removes all listeners previously registered for this path. + */ + @NonBlocking + void unsubscribeAll(); + + /** + * Returns {@code true} if a value currently resolves at this path in the local + * object graph. This is a best-effort check evaluated at call time; the answer may + * change immediately afterwards as remote operations are applied. Useful as a + * guard before performing operations whose semantics depend on existence. + * + *

Complexity is O(n) in the path length because the path must be resolved. + * + * @return {@code true} if the path resolves to a value, {@code false} otherwise + */ + boolean exists(); + + /** + * Returns this {@code PathObject} wrapped as a {@link LiveMapPathObject}. + * + *

This is a best-effort cast - it does not validate that the underlying value + * at this path is a {@code LiveMap}. Read operations are always permitted on the + * returned wrapper; write or terminal operations that require resolution will fail + * at call time if the resolved value is not a {@code LiveMap}. + * + * @return a {@link LiveMapPathObject} view of this path + */ + @NotNull LiveMapPathObject asLiveMap(); + + /** + * Returns this {@code PathObject} wrapped as a {@link LiveCounterPathObject}. + * Best-effort cast; does not validate the underlying type at this path. + * + * @return a {@link LiveCounterPathObject} view of this path + */ + @NotNull LiveCounterPathObject asLiveCounter(); + + /** + * Returns this {@code PathObject} wrapped as a {@link NumberPathObject}. + * Best-effort cast; does not validate the underlying type at this path. + * + * @return a {@link NumberPathObject} view of this path + */ + @NotNull NumberPathObject asNumber(); + + /** + * Returns this {@code PathObject} wrapped as a {@link StringPathObject}. + * Best-effort cast; does not validate the underlying type at this path. + * + * @return a {@link StringPathObject} view of this path + */ + @NotNull StringPathObject asString(); + + /** + * Returns this {@code PathObject} wrapped as a {@link BooleanPathObject}. + * Best-effort cast; does not validate the underlying type at this path. + * + * @return a {@link BooleanPathObject} view of this path + */ + @NotNull BooleanPathObject asBoolean(); + + /** + * Returns this {@code PathObject} wrapped as a {@link BinaryPathObject}. + * Best-effort cast; does not validate the underlying type at this path. + * + * @return a {@link BinaryPathObject} view of this path + */ + @NotNull BinaryPathObject asBinary(); + + /** + * Returns this {@code PathObject} wrapped as a {@link JsonObjectPathObject}. + * Best-effort cast; does not validate the underlying type at this path. + * + * @return a {@link JsonObjectPathObject} view of this path + */ + @NotNull JsonObjectPathObject asJsonObject(); + + /** + * Returns this {@code PathObject} wrapped as a {@link JsonArrayPathObject}. + * Best-effort cast; does not validate the underlying type at this path. + * + * @return a {@link JsonArrayPathObject} view of this path + */ + @NotNull JsonArrayPathObject asJsonArray(); + + /** + * Listener interface for {@link PathObject#subscribe(Listener) path-based subscriptions}. + * + *

Spec: RTPO19a1 + */ + interface Listener { + /** + * Invoked when a change is applied at, or beneath, the subscribed path according + * to the configured {@link SubscriptionOptions}. + * + * @param event the event describing the change + */ + void onUpdated(@NotNull SubscriptionEvent event); + } + + /** + * Event delivered to {@link Listener#onUpdated(SubscriptionEvent)} when a change + * affects the subscribed path. + * + *

Spec: RTPO19e + */ + interface SubscriptionEvent { + /** + * Returns a {@link PathObject} pointing to the path where the change occurred. + * + *

Spec: RTPO19e1 + * + * @return the {@code PathObject} at the changed path + */ + @NotNull PathObject getObject(); + } + + /** + * Optional subscription options accepted by + * {@link PathObject#subscribe(Listener, SubscriptionOptions)}. + * + *

Spec: RTPO19c + */ + final class SubscriptionOptions { + + private final Integer depth; + + /** + * Creates options with the given {@code depth}. + * + * @param depth how many levels of path nesting below the subscribed path should + * trigger the listener; must be a positive integer if provided + */ + public SubscriptionOptions(@Nullable Integer depth) { + this.depth = depth; + } + + /** + * Returns the configured nesting depth, or {@code null} if not set. + * + *

Spec: RTPO19c1 + * + * @return the depth value, or {@code null} + */ + @Nullable + public Integer getDepth() { + return depth; + } + } +} diff --git a/lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java new file mode 100644 index 000000000..0765f33e1 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java @@ -0,0 +1,27 @@ +package io.ably.lib.object.path.types; + +import io.ably.lib.object.path.PathObject; +import org.jetbrains.annotations.Nullable; + +/** + * A {@link PathObject} whose underlying value is expected to be a binary blob + * (a {@code byte[]}). + * + *

This is a terminal type. {@link PathObject#at(String)} remains purely + * navigational and will return a new {@link PathObject} whose later read/write + * operations fail to resolve. {@link PathObject#instance()} returns {@code null} + * because a primitive resolution does not produce a wrapped LiveObject. + * Only {@link #value()} and the inherited read APIs are useful here. + */ +public interface BinaryPathObject extends PathObject { + + /** + * Returns the binary value at this path, or {@code null} when the path does not + * resolve or resolves to a non-binary value. + * + *

Spec: RTPO7 + * + * @return the resolved bytes, or {@code null} + */ + byte @Nullable [] value(); +} diff --git a/lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java new file mode 100644 index 000000000..2d083e274 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java @@ -0,0 +1,27 @@ +package io.ably.lib.object.path.types; + +import io.ably.lib.object.path.PathObject; +import org.jetbrains.annotations.Nullable; + +/** + * A {@link PathObject} whose underlying value is expected to be a {@code Boolean}. + * + *

This is a terminal type. {@link PathObject#at(String)} remains purely + * navigational and will return a new {@link PathObject} whose later read/write + * operations fail to resolve. {@link PathObject#instance()} returns {@code null} + * because a primitive resolution does not produce a wrapped LiveObject. + * Only {@link #value()} and the inherited read APIs are useful here. + */ +public interface BooleanPathObject extends PathObject { + + /** + * Returns the boolean at this path, or {@code null} when the path does not resolve + * or resolves to a non-boolean value. + * + *

Spec: RTPO7 + * + * @return the resolved boolean, or {@code null} + */ + @Nullable + Boolean value(); +} diff --git a/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java new file mode 100644 index 000000000..f6ffa77d0 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java @@ -0,0 +1,28 @@ +package io.ably.lib.object.path.types; + +import com.google.gson.JsonArray; +import io.ably.lib.object.path.PathObject; +import org.jetbrains.annotations.Nullable; + +/** + * A {@link PathObject} whose underlying value is expected to be a {@link JsonArray}. + * + *

This is a terminal type. {@link PathObject#at(String)} remains purely + * navigational and will return a new {@link PathObject} whose later read/write + * operations fail to resolve. {@link PathObject#instance()} returns {@code null} + * because a primitive resolution does not produce a wrapped LiveObject. + * Only {@link #value()} and the inherited read APIs are useful here. + */ +public interface JsonArrayPathObject extends PathObject { + + /** + * Returns the JSON array at this path, or {@code null} when the path does not + * resolve or resolves to a non-JsonArray value. + * + *

Spec: RTPO7 + * + * @return the resolved JsonArray, or {@code null} + */ + @Nullable + JsonArray value(); +} diff --git a/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java new file mode 100644 index 000000000..3d9895240 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java @@ -0,0 +1,28 @@ +package io.ably.lib.object.path.types; + +import com.google.gson.JsonObject; +import io.ably.lib.object.path.PathObject; +import org.jetbrains.annotations.Nullable; + +/** + * A {@link PathObject} whose underlying value is expected to be a {@link JsonObject}. + * + *

This is a terminal type. {@link PathObject#at(String)} remains purely + * navigational and will return a new {@link PathObject} whose later read/write + * operations fail to resolve. {@link PathObject#instance()} returns {@code null} + * because a primitive resolution does not produce a wrapped LiveObject. + * Only {@link #value()} and the inherited read APIs are useful here. + */ +public interface JsonObjectPathObject extends PathObject { + + /** + * Returns the JSON object at this path, or {@code null} when the path does not + * resolve or resolves to a non-JsonObject value. + * + *

Spec: RTPO7 + * + * @return the resolved JsonObject, or {@code null} + */ + @Nullable + JsonObject value(); +} diff --git a/lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java new file mode 100644 index 000000000..a0893dd74 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java @@ -0,0 +1,86 @@ +package io.ably.lib.object.path.types; + +import io.ably.lib.object.path.PathObject; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.concurrent.CompletableFuture; + +/** + * A {@link PathObject} whose underlying value is expected to be a {@code LiveCounter}. + * Provides type-safe access to counter operations such as {@link #value()}, + * {@link #increment(Number)} and {@link #decrement(Number)}. + * + *

Counters are terminal nodes. {@link PathObject#at(String)} remains purely + * navigational and will return a new {@link PathObject} whose later read/write + * operations fail to resolve. + * + *

Operations are best-effort and resolve the path at call time. Read operations + * return {@code null} when the path does not resolve to a {@code LiveCounter}; write + * operations complete the returned {@link CompletableFuture} exceptionally with an + * {@code AblyException} (status 400, code 92007) in that case. + */ +public interface LiveCounterPathObject extends PathObject { + + /** + * Returns the current value of the {@code LiveCounter} at this path, or {@code null} + * when the path does not resolve to a {@code LiveCounter}. + * + *

Spec: RTPO7 / RTLC5 + * + * @return the counter value, or {@code null} + */ + @Nullable + Double value(); + + /** + * Increments the {@code LiveCounter} at this path by {@code 1}. Equivalent to + * calling {@link #increment(Number)} with {@code 1}. + * + *

Spec: RTPO17a1 (default {@code amount} of {@code 1}) + * + * @return a future that completes when the operation has been acknowledged + */ + @NotNull + CompletableFuture increment(); + + /** + * Increments the {@code LiveCounter} at this path by {@code amount}. + * + *

Sends a {@code COUNTER_INC} operation to the realtime system; the local state + * is updated when the operation is echoed back. The returned future completes + * exceptionally with an {@code AblyException} (status 400, code 92005) if the path + * cannot be resolved, or (status 400, code 92007) if the resolved value is not a + * {@code LiveCounter}. + * + *

Spec: RTPO17 + * + * @param amount the amount to add (may be negative) + * @return a future that completes when the operation has been acknowledged + */ + @NotNull + CompletableFuture increment(@NotNull Number amount); + + /** + * Decrements the {@code LiveCounter} at this path by {@code 1}. Equivalent to + * calling {@link #decrement(Number)} with {@code 1}. + * + *

Spec: RTPO18a1 (default {@code amount} of {@code 1}) + * + * @return a future that completes when the operation has been acknowledged + */ + @NotNull + CompletableFuture decrement(); + + /** + * Decrements the {@code LiveCounter} at this path by {@code amount}. Equivalent to + * calling {@link #increment(Number)} with a negated value. + * + *

Spec: RTPO18 + * + * @param amount the amount to subtract (may be negative) + * @return a future that completes when the operation has been acknowledged + */ + @NotNull + CompletableFuture decrement(@NotNull Number amount); +} diff --git a/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java new file mode 100644 index 000000000..5e04fda3e --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java @@ -0,0 +1,125 @@ +package io.ably.lib.object.path.types; + +import io.ably.lib.object.path.PathObject; +import io.ably.lib.objects.type.map.LiveMapValue; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * A {@link PathObject} whose underlying value is expected to be a {@code LiveMap}. + * Provides type-safe access to map-specific operations such as {@link #get(String)}, + * {@link #entries()}, {@link #set(String, LiveMapValue)}, etc. + * + *

Calling {@code channel.objects.getRoot()}-equivalent navigation methods at the + * root of the graph always returns a {@code LiveMapPathObject}. + * + *

Operations on this type are best-effort: they resolve the path against the local + * LiveObjects graph at call time. Read operations return empty/null when the path does + * not resolve to a {@code LiveMap}; write operations complete the returned + * {@link CompletableFuture} exceptionally with an {@code AblyException} + * (status 400, code 92007) in that case. + */ +public interface LiveMapPathObject extends PathObject { + + /** + * Returns a new {@link PathObject} representing the child at {@code key} of the + * {@code LiveMap} at this path. Purely navigational - no resolution occurs. + * + *

Spec: RTPO5 + * + * @param key the child key to navigate to + * @return a {@link PathObject} pointing to {@code this.path + key} + */ + @NotNull + PathObject get(@NotNull String key); + + /** + * Returns the entries (key, child {@link PathObject}) of the {@code LiveMap} at + * this path. Each child path is produced as if by calling {@link #get(String)} with + * the corresponding key. + * + *

Returns an empty iterable when the path does not resolve to a {@code LiveMap}. + * + *

Spec: RTPO9 + * + * @return an unmodifiable iterable of map entries; empty when not a LiveMap + */ + @NotNull + @Unmodifiable + Iterable> entries(); + + /** + * Returns the keys of the {@code LiveMap} at this path. + * + *

Returns an empty iterable when the path does not resolve to a {@code LiveMap}. + * + *

Spec: RTPO10 + * + * @return an unmodifiable iterable of keys; empty when not a LiveMap + */ + @NotNull + @Unmodifiable + Iterable keys(); + + /** + * Returns the child {@link PathObject}s for each key in the {@code LiveMap} at this + * path. + * + *

Returns an empty iterable when the path does not resolve to a {@code LiveMap}. + * + *

Spec: RTPO11 + * + * @return an unmodifiable iterable of child paths; empty when not a LiveMap + */ + @NotNull + @Unmodifiable + Iterable values(); + + /** + * Returns the size of the {@code LiveMap} at this path, or {@code null} when the + * path does not resolve to a {@code LiveMap}. + * + *

Spec: RTPO12 + * + * @return the number of (non-tombstoned) entries, or {@code null} + */ + @Nullable + Long size(); + + /** + * Sets a key on the {@code LiveMap} at this path to the provided value. + * + *

Sends a {@code MAP_SET} operation to the realtime system; the local state is + * updated when the operation is echoed back. The returned future completes + * exceptionally with an {@code AblyException} (status 400, code 92005) if the path + * cannot be resolved, or (status 400, code 92007) if the resolved value is not a + * {@code LiveMap}. + * + *

Spec: RTPO15 + * + * @param key the key to set + * @param value the value to associate with {@code key} + * @return a future that completes when the operation has been acknowledged + */ + @NotNull + CompletableFuture set(@NotNull String key, @NotNull LiveMapValue value); + + /** + * Removes a key from the {@code LiveMap} at this path. + * + *

Sends a {@code MAP_REMOVE} operation to the realtime system; the local state + * is updated when the operation is echoed back. Same error conditions as + * {@link #set(String, LiveMapValue)} apply. + * + *

Spec: RTPO16 + * + * @param key the key to remove + * @return a future that completes when the operation has been acknowledged + */ + @NotNull + CompletableFuture remove(@NotNull String key); +} diff --git a/lib/src/main/java/io/ably/lib/object/path/types/NumberPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/NumberPathObject.java new file mode 100644 index 000000000..5f0b5986d --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/path/types/NumberPathObject.java @@ -0,0 +1,27 @@ +package io.ably.lib.object.path.types; + +import io.ably.lib.object.path.PathObject; +import org.jetbrains.annotations.Nullable; + +/** + * A {@link PathObject} whose underlying value is expected to be a {@code Number}. + * + *

This is a terminal type. {@link PathObject#at(String)} remains purely + * navigational and will return a new {@link PathObject} whose later read/write + * operations fail to resolve. {@link PathObject#instance()} returns {@code null} + * because a primitive resolution does not produce a wrapped LiveObject. + * Only {@link #value()} and the inherited read APIs are useful here. + */ +public interface NumberPathObject extends PathObject { + + /** + * Returns the number at this path, or {@code null} when the path does not resolve + * or resolves to a non-numeric value. + * + *

Spec: RTPO7 + * + * @return the resolved number, or {@code null} + */ + @Nullable + Number value(); +} diff --git a/lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java new file mode 100644 index 000000000..c033219df --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java @@ -0,0 +1,27 @@ +package io.ably.lib.object.path.types; + +import io.ably.lib.object.path.PathObject; +import org.jetbrains.annotations.Nullable; + +/** + * A {@link PathObject} whose underlying value is expected to be a {@code String}. + * + *

This is a terminal type. {@link PathObject#at(String)} remains purely + * navigational and will return a new {@link PathObject} whose later read/write + * operations fail to resolve. {@link PathObject#instance()} returns {@code null} + * because a primitive resolution does not produce a wrapped LiveObject. + * Only {@link #value()} and the inherited read APIs are useful here. + */ +public interface StringPathObject extends PathObject { + + /** + * Returns the string at this path, or {@code null} when the path does not resolve + * or resolves to a non-string value. + * + *

Spec: RTPO7 + * + * @return the resolved string, or {@code null} + */ + @Nullable + String value(); +} From e226ba45219544985ec263b8615dff622c11f8ed Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Tue, 9 Jun 2026 16:14:27 +0530 Subject: [PATCH 02/40] Updated PathObject and Instance classes/sub-classes as per finalized assessment doc --- .../{ObjectType.java => ValueType.java} | 2 +- ...{LiveObjectInstance.java => Instance.java} | 50 ++++------------ .../object/instance/types/BinaryInstance.java | 12 ++-- .../instance/types/BooleanInstance.java | 10 ++-- .../instance/types/JsonArrayInstance.java | 10 ++-- .../instance/types/JsonObjectInstance.java | 10 ++-- .../instance/types/LiveCounterInstance.java | 16 ++++- .../instance/types/LiveMapInstance.java | 28 ++++++--- .../object/instance/types/NumberInstance.java | 10 ++-- .../object/instance/types/StringInstance.java | 10 ++-- .../io/ably/lib/object/path/PathObject.java | 59 ++++--------------- .../object/path/types/BinaryPathObject.java | 10 ++-- .../object/path/types/BooleanPathObject.java | 10 ++-- .../path/types/JsonArrayPathObject.java | 10 ++-- .../path/types/JsonObjectPathObject.java | 10 ++-- .../path/types/LiveCounterPathObject.java | 5 +- .../object/path/types/LiveMapPathObject.java | 21 +++++++ .../object/path/types/NumberPathObject.java | 10 ++-- .../object/path/types/StringPathObject.java | 10 ++-- 19 files changed, 137 insertions(+), 166 deletions(-) rename lib/src/main/java/io/ably/lib/object/{ObjectType.java => ValueType.java} (86%) rename lib/src/main/java/io/ably/lib/object/instance/{LiveObjectInstance.java => Instance.java} (80%) diff --git a/lib/src/main/java/io/ably/lib/object/ObjectType.java b/lib/src/main/java/io/ably/lib/object/ValueType.java similarity index 86% rename from lib/src/main/java/io/ably/lib/object/ObjectType.java rename to lib/src/main/java/io/ably/lib/object/ValueType.java index bef18ae95..4f1cb59a5 100644 --- a/lib/src/main/java/io/ably/lib/object/ObjectType.java +++ b/lib/src/main/java/io/ably/lib/object/ValueType.java @@ -1,6 +1,6 @@ package io.ably.lib.object; -public enum ObjectType { +public enum ValueType { STRING, NUMBER, BOOLEAN, diff --git a/lib/src/main/java/io/ably/lib/object/instance/LiveObjectInstance.java b/lib/src/main/java/io/ably/lib/object/instance/Instance.java similarity index 80% rename from lib/src/main/java/io/ably/lib/object/instance/LiveObjectInstance.java rename to lib/src/main/java/io/ably/lib/object/instance/Instance.java index f5bbfbb90..c52a73d11 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/LiveObjectInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/Instance.java @@ -1,7 +1,7 @@ package io.ably.lib.object.instance; import com.google.gson.JsonElement; -import io.ably.lib.object.ObjectType; +import io.ably.lib.object.ValueType; import io.ably.lib.object.instance.types.BinaryInstance; import io.ably.lib.object.instance.types.BooleanInstance; import io.ably.lib.object.instance.types.JsonArrayInstance; @@ -13,41 +13,30 @@ import io.ably.lib.objects.ObjectsSubscription; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; /** * A direct-reference view of a single LiveObject (a {@code LiveMap} or {@code LiveCounter}) * or a primitive value. Unlike {@code PathObject}, which resolves a path lazily against * the LiveObjects graph at every call, an {@code Instance} is bound to a specific - * underlying value identified by its object id (for live objects) and dereferenced in - * O(1). + * underlying value and dereferenced in O(1). * *

Java exposes type-specific sub-types ({@link LiveMapInstance}, * {@link LiveCounterInstance}, and the primitive {@code *Instance} types). Use the * {@code as*} helpers to obtain a sub-type wrapper without performing type validation. + * Only {@link LiveMapInstance} and {@link LiveCounterInstance} expose an object id + * (via their own {@code getId()} methods); primitive instances are anonymous. * *

Spec: RTINS1 */ -public interface LiveObjectInstance { +public interface Instance { /** - * Returns the {@link ObjectType} of the value wrapped by this instance. Use this + * Returns the {@link ValueType} of the value wrapped by this instance. Use this * instead of dedicated {@code isLiveMap}/{@code isLiveCounter}/etc. checks. * - * @return the wrapped object type + * @return the wrapped value type */ - @NotNull ObjectType getType(); - - /** - * Returns the object id of the wrapped LiveObject, or {@code null} when the wrapped - * value is a primitive. Only {@link LiveMapInstance} and {@link LiveCounterInstance} - * ever return a non-null id. - * - *

Spec: RTINS3 - * - * @return the wrapped object's id, or {@code null} for primitive instances - */ - @Nullable String getId(); + @NotNull ValueType getType(); /** * Returns a JSON-serializable, recursively compacted snapshot of the wrapped value. @@ -65,6 +54,8 @@ public interface LiveObjectInstance { /** * Subscribes a listener for updates on the underlying LiveObject. The listener is * invoked whenever the wrapped object is changed by a local or remote operation. + * Call {@link ObjectsSubscription#unsubscribe()} on the returned handle to stop + * receiving events for this listener. * *

Subscribe is not supported on primitive instances; implementations may throw * when called on {@link NumberInstance}, {@link StringInstance}, @@ -79,21 +70,6 @@ public interface LiveObjectInstance { @NonBlocking @NotNull ObjectsSubscription subscribe(@NotNull Listener listener); - /** - * Unsubscribes the specified listener previously registered via - * {@link #subscribe(Listener)}. No-op if the listener is not currently subscribed. - * - * @param listener the listener to remove - */ - @NonBlocking - void unsubscribe(@NotNull Listener listener); - - /** - * Removes all listeners previously registered on this instance. - */ - @NonBlocking - void unsubscribeAll(); - /** * Returns this instance wrapped as a {@link LiveMapInstance}. * @@ -162,7 +138,7 @@ public interface LiveObjectInstance { @NotNull JsonArrayInstance asJsonArray(); /** - * Listener interface for {@link LiveObjectInstance#subscribe(Listener) instance + * Listener interface for {@link Instance#subscribe(Listener) instance * subscriptions}. * *

Spec: RTINS16a1 @@ -184,10 +160,10 @@ interface Listener { */ interface SubscriptionEvent { /** - * Returns the {@link LiveObjectInstance} that was updated. + * Returns the {@link Instance} that was updated. * * @return the updated instance */ - @NotNull LiveObjectInstance getInstance(); + @NotNull Instance getInstance(); } } diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java index d0ef51a26..64aa8ba31 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java @@ -1,16 +1,14 @@ package io.ably.lib.object.instance.types; -import io.ably.lib.object.instance.LiveObjectInstance; +import io.ably.lib.object.instance.Instance; import org.jetbrains.annotations.NotNull; /** - * A read-only {@link LiveObjectInstance} bound to a binary primitive value - * (a {@code byte[]}). - * - *

{@link #getId()} always returns {@code null} for primitive instances, and - * subscribe operations are not supported. + * A read-only {@link Instance} bound to a binary primitive value + * (a {@code byte[]}). Primitive instances are anonymous (no object id) and do not + * support subscribe. */ -public interface BinaryInstance extends LiveObjectInstance { +public interface BinaryInstance extends Instance { /** * Returns the wrapped binary value. diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java index 90c2ec3f8..b8516fda6 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java @@ -1,15 +1,13 @@ package io.ably.lib.object.instance.types; -import io.ably.lib.object.instance.LiveObjectInstance; +import io.ably.lib.object.instance.Instance; import org.jetbrains.annotations.NotNull; /** - * A read-only {@link LiveObjectInstance} bound to a {@code Boolean} primitive value. - * - *

{@link #getId()} always returns {@code null} for primitive instances, and - * subscribe operations are not supported. + * A read-only {@link Instance} bound to a {@code Boolean} primitive value. + * Primitive instances are anonymous (no object id) and do not support subscribe. */ -public interface BooleanInstance extends LiveObjectInstance { +public interface BooleanInstance extends Instance { /** * Returns the wrapped boolean. diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java index fe5c5b99b..b04c42da0 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java @@ -1,16 +1,14 @@ package io.ably.lib.object.instance.types; import com.google.gson.JsonArray; -import io.ably.lib.object.instance.LiveObjectInstance; +import io.ably.lib.object.instance.Instance; import org.jetbrains.annotations.NotNull; /** - * A read-only {@link LiveObjectInstance} bound to a {@link JsonArray} primitive value. - * - *

{@link #getId()} always returns {@code null} for primitive instances, and - * subscribe operations are not supported. + * A read-only {@link Instance} bound to a {@link JsonArray} primitive value. + * Primitive instances are anonymous (no object id) and do not support subscribe. */ -public interface JsonArrayInstance extends LiveObjectInstance { +public interface JsonArrayInstance extends Instance { /** * Returns the wrapped JSON array. diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java index 7a8c0bb4e..6c0254a46 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java @@ -1,16 +1,14 @@ package io.ably.lib.object.instance.types; import com.google.gson.JsonObject; -import io.ably.lib.object.instance.LiveObjectInstance; +import io.ably.lib.object.instance.Instance; import org.jetbrains.annotations.NotNull; /** - * A read-only {@link LiveObjectInstance} bound to a {@link JsonObject} primitive value. - * - *

{@link #getId()} always returns {@code null} for primitive instances, and - * subscribe operations are not supported. + * A read-only {@link Instance} bound to a {@link JsonObject} primitive value. + * Primitive instances are anonymous (no object id) and do not support subscribe. */ -public interface JsonObjectInstance extends LiveObjectInstance { +public interface JsonObjectInstance extends Instance { /** * Returns the wrapped JSON object. diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java index a05d4f15b..a63b0f2fb 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java @@ -1,16 +1,26 @@ package io.ably.lib.object.instance.types; -import io.ably.lib.object.instance.LiveObjectInstance; +import io.ably.lib.object.instance.Instance; import org.jetbrains.annotations.NotNull; import java.util.concurrent.CompletableFuture; /** - * A {@link LiveObjectInstance} bound to a {@code LiveCounter}. Provides type-safe + * A {@link Instance} bound to a {@code LiveCounter}. Provides type-safe * access to counter operations such as {@link #value()}, {@link #increment(Number)} * and {@link #decrement(Number)}. */ -public interface LiveCounterInstance extends LiveObjectInstance { +public interface LiveCounterInstance extends Instance { + + /** + * Returns the object id of the wrapped {@code LiveCounter}. + * + *

Spec: RTINS3a + * + * @return the wrapped {@code LiveCounter}'s object id + */ + @NotNull + String getId(); /** * Returns the current value of the wrapped {@code LiveCounter}. diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java index 93ef30182..c9e46df34 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java @@ -1,6 +1,6 @@ package io.ably.lib.object.instance.types; -import io.ably.lib.object.instance.LiveObjectInstance; +import io.ably.lib.object.instance.Instance; import io.ably.lib.objects.type.map.LiveMapValue; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -10,17 +10,27 @@ import java.util.concurrent.CompletableFuture; /** - * A {@link LiveObjectInstance} bound to a {@code LiveMap}. Provides type-safe access to + * A {@link Instance} bound to a {@code LiveMap}. Provides type-safe access to * map-specific operations such as {@link #get(String)}, {@link #entries()} and * {@link #set(String, LiveMapValue)}. * *

Operations are bound to the specific underlying {@code LiveMap}, dereferenced in * O(1), and do not perform any path resolution. */ -public interface LiveMapInstance extends LiveObjectInstance { +public interface LiveMapInstance extends Instance { /** - * Returns a {@link LiveObjectInstance} wrapping the value at {@code key} of the + * Returns the object id of the wrapped {@code LiveMap}. + * + *

Spec: RTINS3a + * + * @return the wrapped {@code LiveMap}'s object id + */ + @NotNull + String getId(); + + /** + * Returns a {@link Instance} wrapping the value at {@code key} of the * wrapped {@code LiveMap}, or {@code null} when the key is absent / tombstoned. * *

Spec: RTINS5 @@ -29,10 +39,10 @@ public interface LiveMapInstance extends LiveObjectInstance { * @return an instance wrapping the value at {@code key}, or {@code null} */ @Nullable - LiveObjectInstance get(@NotNull String key); + Instance get(@NotNull String key); /** - * Returns the entries (key, child {@link LiveObjectInstance}) of the wrapped + * Returns the entries (key, child {@link Instance}) of the wrapped * {@code LiveMap}. * *

Spec: RTINS6 @@ -41,7 +51,7 @@ public interface LiveMapInstance extends LiveObjectInstance { */ @NotNull @Unmodifiable - Iterable> entries(); + Iterable> entries(); /** * Returns the keys of the wrapped {@code LiveMap}. @@ -55,7 +65,7 @@ public interface LiveMapInstance extends LiveObjectInstance { Iterable keys(); /** - * Returns the child {@link LiveObjectInstance}s for each value in the wrapped + * Returns the child {@link Instance}s for each value in the wrapped * {@code LiveMap}. * *

Spec: RTINS8 @@ -64,7 +74,7 @@ public interface LiveMapInstance extends LiveObjectInstance { */ @NotNull @Unmodifiable - Iterable values(); + Iterable values(); /** * Returns the number of (non-tombstoned) entries in the wrapped {@code LiveMap}. diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java index a778000cf..3ff4a4041 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java @@ -1,15 +1,13 @@ package io.ably.lib.object.instance.types; -import io.ably.lib.object.instance.LiveObjectInstance; +import io.ably.lib.object.instance.Instance; import org.jetbrains.annotations.NotNull; /** - * A read-only {@link LiveObjectInstance} bound to a {@code Number} primitive value. - * - *

{@link #getId()} always returns {@code null} for primitive instances, and - * subscribe operations are not supported. + * A read-only {@link Instance} bound to a {@code Number} primitive value. + * Primitive instances are anonymous (no object id) and do not support subscribe. */ -public interface NumberInstance extends LiveObjectInstance { +public interface NumberInstance extends Instance { /** * Returns the wrapped number. diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java index 9639adfda..9b4a41104 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java @@ -1,15 +1,13 @@ package io.ably.lib.object.instance.types; -import io.ably.lib.object.instance.LiveObjectInstance; +import io.ably.lib.object.instance.Instance; import org.jetbrains.annotations.NotNull; /** - * A read-only {@link LiveObjectInstance} bound to a {@code String} primitive value. - * - *

{@link #getId()} always returns {@code null} for primitive instances, and - * subscribe operations are not supported. + * A read-only {@link Instance} bound to a {@code String} primitive value. + * Primitive instances are anonymous (no object id) and do not support subscribe. */ -public interface StringInstance extends LiveObjectInstance { +public interface StringInstance extends Instance { /** * Returns the wrapped string. diff --git a/lib/src/main/java/io/ably/lib/object/path/PathObject.java b/lib/src/main/java/io/ably/lib/object/path/PathObject.java index 0a2aaefd0..6ef38a1c6 100644 --- a/lib/src/main/java/io/ably/lib/object/path/PathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/PathObject.java @@ -1,8 +1,8 @@ package io.ably.lib.object.path; import com.google.gson.JsonElement; -import io.ably.lib.object.ObjectType; -import io.ably.lib.object.instance.LiveObjectInstance; +import io.ably.lib.object.ValueType; +import io.ably.lib.object.instance.Instance; import io.ably.lib.object.path.types.BinaryPathObject; import io.ably.lib.object.path.types.BooleanPathObject; import io.ably.lib.object.path.types.JsonArrayPathObject; @@ -37,12 +37,12 @@ public interface PathObject { /** - * Returns the {@link ObjectType} of the value the resolved at this path currently. + * Returns the {@link ValueType} of the value resolved at this path currently. * Use this instead of dedicated {@code isLiveMap}/{@code isLiveCounter}/etc. checks. * - * @return the resolved object type at this path + * @return the resolved value type at this path */ - @NotNull ObjectType getType(); + @NotNull ValueType getType(); /** * Returns a dot-delimited string representation of the stored path segments. @@ -57,27 +57,7 @@ public interface PathObject { @NotNull String path(); /** - * Returns a new {@code PathObject} whose path is this path with the segments parsed - * from {@code path} appended. The {@code path} argument is a dot-delimited string; - * a backslash-escaped dot ({@code \.}) is treated as a literal dot within a segment. - * - *

This is purely navigational - no resolution against the LiveObjects graph is - * performed by this call. {@code pathObject.at("a.b.c")} is equivalent to - * {@code pathObject.get("a").get("b").get("c")} on a {@link LiveMapPathObject}. - * - *

For primitive {@code *PathObject} sub-types and {@link LiveCounterPathObject}, - * deeper navigation is not meaningful; implementations may throw or return a - * {@code PathObject} that will fail to resolve at read/write time. - * - *

Spec: RTPO6 - * - * @param path dot-delimited path to append to this path - * @return a new {@code PathObject} representing the deeper path - */ - @NotNull PathObject at(@NotNull String path); - - /** - * Resolves this path and returns a {@link LiveObjectInstance} wrapping the underlying + * Resolves this path and returns a {@link Instance} wrapping the underlying * value if it is a {@code LiveMap} or {@code LiveCounter}. * *

Returns {@code null} when the resolved value is a primitive (LiveObjects with @@ -86,9 +66,9 @@ public interface PathObject { * *

Spec: RTPO8 * - * @return a {@link LiveObjectInstance} wrapping the resolved live object, or {@code null} + * @return a {@link Instance} wrapping the resolved live object, or {@code null} */ - @Nullable LiveObjectInstance instance(); + @Nullable Instance instance(); /** * Returns a JSON-serializable, recursively compacted snapshot of the value at this @@ -107,7 +87,8 @@ public interface PathObject { /** * Subscribes a listener for path-based update events. The listener is invoked when * an operation modifies the value at this path. The same path may be subscribed by - * multiple listeners independently. + * multiple listeners independently. Call {@link ObjectsSubscription#unsubscribe()} + * on the returned handle to stop receiving events for this listener. * *

Spec: RTPO19 * @@ -120,7 +101,9 @@ public interface PathObject { /** * Subscribes a listener for path-based update events using the provided * {@link SubscriptionOptions}. Options control coverage rules such as the - * {@code depth} of nested updates that trigger the listener. + * {@code depth} of nested updates that trigger the listener. Call + * {@link ObjectsSubscription#unsubscribe()} on the returned handle to stop + * receiving events for this listener. * *

Spec: RTPO19 * @@ -131,22 +114,6 @@ public interface PathObject { @NonBlocking @NotNull ObjectsSubscription subscribe(@NotNull Listener listener, @Nullable SubscriptionOptions options); - /** - * Unsubscribes the specified listener previously registered via - * {@link #subscribe(Listener)} or {@link #subscribe(Listener, SubscriptionOptions)}. - * No-op if the listener is not currently subscribed for this path. - * - * @param listener the listener to remove - */ - @NonBlocking - void unsubscribe(@NotNull Listener listener); - - /** - * Removes all listeners previously registered for this path. - */ - @NonBlocking - void unsubscribeAll(); - /** * Returns {@code true} if a value currently resolves at this path in the local * object graph. This is a best-effort check evaluated at call time; the answer may diff --git a/lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java index 0765f33e1..ce7b596dd 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java @@ -7,11 +7,11 @@ * A {@link PathObject} whose underlying value is expected to be a binary blob * (a {@code byte[]}). * - *

This is a terminal type. {@link PathObject#at(String)} remains purely - * navigational and will return a new {@link PathObject} whose later read/write - * operations fail to resolve. {@link PathObject#instance()} returns {@code null} - * because a primitive resolution does not produce a wrapped LiveObject. - * Only {@link #value()} and the inherited read APIs are useful here. + *

This is a terminal type. {@link PathObject#instance()} returns {@code null} + * because a primitive resolution does not produce a wrapped LiveObject; navigation + * via {@code at(...)} is not available here because it is only defined on + * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are + * useful here. */ public interface BinaryPathObject extends PathObject { diff --git a/lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java index 2d083e274..1fd62578e 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java @@ -6,11 +6,11 @@ /** * A {@link PathObject} whose underlying value is expected to be a {@code Boolean}. * - *

This is a terminal type. {@link PathObject#at(String)} remains purely - * navigational and will return a new {@link PathObject} whose later read/write - * operations fail to resolve. {@link PathObject#instance()} returns {@code null} - * because a primitive resolution does not produce a wrapped LiveObject. - * Only {@link #value()} and the inherited read APIs are useful here. + *

This is a terminal type. {@link PathObject#instance()} returns {@code null} + * because a primitive resolution does not produce a wrapped LiveObject; navigation + * via {@code at(...)} is not available here because it is only defined on + * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are + * useful here. */ public interface BooleanPathObject extends PathObject { diff --git a/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java index f6ffa77d0..28a97f3c7 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java @@ -7,11 +7,11 @@ /** * A {@link PathObject} whose underlying value is expected to be a {@link JsonArray}. * - *

This is a terminal type. {@link PathObject#at(String)} remains purely - * navigational and will return a new {@link PathObject} whose later read/write - * operations fail to resolve. {@link PathObject#instance()} returns {@code null} - * because a primitive resolution does not produce a wrapped LiveObject. - * Only {@link #value()} and the inherited read APIs are useful here. + *

This is a terminal type. {@link PathObject#instance()} returns {@code null} + * because a primitive resolution does not produce a wrapped LiveObject; navigation + * via {@code at(...)} is not available here because it is only defined on + * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are + * useful here. */ public interface JsonArrayPathObject extends PathObject { diff --git a/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java index 3d9895240..0a2d70db0 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java @@ -7,11 +7,11 @@ /** * A {@link PathObject} whose underlying value is expected to be a {@link JsonObject}. * - *

This is a terminal type. {@link PathObject#at(String)} remains purely - * navigational and will return a new {@link PathObject} whose later read/write - * operations fail to resolve. {@link PathObject#instance()} returns {@code null} - * because a primitive resolution does not produce a wrapped LiveObject. - * Only {@link #value()} and the inherited read APIs are useful here. + *

This is a terminal type. {@link PathObject#instance()} returns {@code null} + * because a primitive resolution does not produce a wrapped LiveObject; navigation + * via {@code at(...)} is not available here because it is only defined on + * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are + * useful here. */ public interface JsonObjectPathObject extends PathObject { diff --git a/lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java index a0893dd74..dde18fca9 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java @@ -11,9 +11,8 @@ * Provides type-safe access to counter operations such as {@link #value()}, * {@link #increment(Number)} and {@link #decrement(Number)}. * - *

Counters are terminal nodes. {@link PathObject#at(String)} remains purely - * navigational and will return a new {@link PathObject} whose later read/write - * operations fail to resolve. + *

Counters are terminal nodes - navigation via {@code at(...)} is not available + * here because it is only defined on {@code LiveMapPathObject}. * *

Operations are best-effort and resolve the path at call time. Read operations * return {@code null} when the path does not resolve to a {@code LiveCounter}; write diff --git a/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java index 5e04fda3e..c52d3aa6c 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java @@ -37,6 +37,27 @@ public interface LiveMapPathObject extends PathObject { @NotNull PathObject get(@NotNull String key); + /** + * Returns a new {@link PathObject} whose path is this path with the segments parsed + * from {@code path} appended. The {@code path} argument is a dot-delimited string; + * a backslash-escaped dot ({@code \.}) is treated as a literal dot within a segment. + * + *

This is purely navigational - no resolution against the LiveObjects graph is + * performed by this call. {@code liveMapPath.at("a.b.c")} is equivalent to + * {@code liveMapPath.get("a").get("b").get("c")}. + * + *

Available only on {@code LiveMapPathObject} because deeper navigation is only + * meaningful when the current resolved value is a {@code LiveMap}. To traverse from + * an arbitrary {@link PathObject}, first cast via {@link PathObject#asLiveMap()}. + * + *

Spec: RTPO6 + * + * @param path dot-delimited path to append to this path + * @return a new {@link PathObject} representing the deeper path + */ + @NotNull + PathObject at(@NotNull String path); + /** * Returns the entries (key, child {@link PathObject}) of the {@code LiveMap} at * this path. Each child path is produced as if by calling {@link #get(String)} with diff --git a/lib/src/main/java/io/ably/lib/object/path/types/NumberPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/NumberPathObject.java index 5f0b5986d..ca2c4a3c2 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/NumberPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/NumberPathObject.java @@ -6,11 +6,11 @@ /** * A {@link PathObject} whose underlying value is expected to be a {@code Number}. * - *

This is a terminal type. {@link PathObject#at(String)} remains purely - * navigational and will return a new {@link PathObject} whose later read/write - * operations fail to resolve. {@link PathObject#instance()} returns {@code null} - * because a primitive resolution does not produce a wrapped LiveObject. - * Only {@link #value()} and the inherited read APIs are useful here. + *

This is a terminal type. {@link PathObject#instance()} returns {@code null} + * because a primitive resolution does not produce a wrapped LiveObject; navigation + * via {@code at(...)} is not available here because it is only defined on + * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are + * useful here. */ public interface NumberPathObject extends PathObject { diff --git a/lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java index c033219df..d520168d2 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java @@ -6,11 +6,11 @@ /** * A {@link PathObject} whose underlying value is expected to be a {@code String}. * - *

This is a terminal type. {@link PathObject#at(String)} remains purely - * navigational and will return a new {@link PathObject} whose later read/write - * operations fail to resolve. {@link PathObject#instance()} returns {@code null} - * because a primitive resolution does not produce a wrapped LiveObject. - * Only {@link #value()} and the inherited read APIs are useful here. + *

This is a terminal type. {@link PathObject#instance()} returns {@code null} + * because a primitive resolution does not produce a wrapped LiveObject; navigation + * via {@code at(...)} is not available here because it is only defined on + * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are + * useful here. */ public interface StringPathObject extends PathObject { From 99d9dd9716accc956c0587fb3d6ceecfd92ce010 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 10 Jun 2026 17:13:42 +0530 Subject: [PATCH 03/40] Refactored/Updated public API types as per spec --- .../java/io/ably/lib/object/Subscription.java | 30 ++ .../java/io/ably/lib/object/ValueType.java | 15 + .../io/ably/lib/object/instance/Instance.java | 104 ++--- .../lib/object/instance/InstanceListener.java | 22 + .../instance/InstanceSubscriptionEvent.java | 37 ++ .../lib/object/instance/package-info.java | 12 + .../object/instance/types/BinaryInstance.java | 10 +- .../instance/types/BooleanInstance.java | 8 +- .../instance/types/JsonArrayInstance.java | 8 +- .../instance/types/JsonObjectInstance.java | 8 +- .../instance/types/LiveCounterInstance.java | 22 + .../instance/types/LiveMapInstance.java | 24 +- .../object/instance/types/NumberInstance.java | 8 +- .../object/instance/types/StringInstance.java | 8 +- .../object/instance/types/package-info.java | 11 + .../lib/object/message/CounterCreate.java | 21 + .../ably/lib/object/message/CounterInc.java | 22 + .../io/ably/lib/object/message/MapClear.java | 12 + .../io/ably/lib/object/message/MapCreate.java | 33 ++ .../io/ably/lib/object/message/MapRemove.java | 21 + .../io/ably/lib/object/message/MapSet.java | 30 ++ .../ably/lib/object/message/ObjectData.java | 70 +++ .../ably/lib/object/message/ObjectDelete.java | 13 + .../lib/object/message/ObjectMessage.java | 135 ++++++ .../lib/object/message/ObjectOperation.java | 106 +++++ .../object/message/ObjectOperationAction.java | 37 ++ .../lib/object/message/ObjectsMapEntry.java | 51 ++ .../object/message/ObjectsMapSemantics.java | 18 + .../ably/lib/object/message/package-info.java | 26 ++ .../java/io/ably/lib/object/package-info.java | 17 + .../io/ably/lib/object/path/PathObject.java | 140 +++--- .../lib/object/path/PathObjectListener.java | 21 + .../path/PathObjectSubscriptionEvent.java | 34 ++ .../path/PathObjectSubscriptionOptions.java | 36 ++ .../io/ably/lib/object/path/package-info.java | 13 + .../object/path/types/BinaryPathObject.java | 4 +- .../object/path/types/BooleanPathObject.java | 4 +- .../path/types/JsonArrayPathObject.java | 4 +- .../path/types/JsonObjectPathObject.java | 4 +- .../path/types/LiveCounterPathObject.java | 2 + .../object/path/types/LiveMapPathObject.java | 4 +- .../object/path/types/NumberPathObject.java | 4 +- .../object/path/types/StringPathObject.java | 4 +- .../lib/object/path/types/package-info.java | 11 + .../io/ably/lib/object/value/LiveCounter.java | 72 +++ .../io/ably/lib/object/value/LiveMap.java | 75 +++ .../ably/lib/object/value/LiveMapValue.java | 439 ++++++++++++++++++ .../ably/lib/object/value/package-info.java | 16 + 48 files changed, 1655 insertions(+), 171 deletions(-) create mode 100644 lib/src/main/java/io/ably/lib/object/Subscription.java create mode 100644 lib/src/main/java/io/ably/lib/object/instance/InstanceListener.java create mode 100644 lib/src/main/java/io/ably/lib/object/instance/InstanceSubscriptionEvent.java create mode 100644 lib/src/main/java/io/ably/lib/object/instance/package-info.java create mode 100644 lib/src/main/java/io/ably/lib/object/instance/types/package-info.java create mode 100644 lib/src/main/java/io/ably/lib/object/message/CounterCreate.java create mode 100644 lib/src/main/java/io/ably/lib/object/message/CounterInc.java create mode 100644 lib/src/main/java/io/ably/lib/object/message/MapClear.java create mode 100644 lib/src/main/java/io/ably/lib/object/message/MapCreate.java create mode 100644 lib/src/main/java/io/ably/lib/object/message/MapRemove.java create mode 100644 lib/src/main/java/io/ably/lib/object/message/MapSet.java create mode 100644 lib/src/main/java/io/ably/lib/object/message/ObjectData.java create mode 100644 lib/src/main/java/io/ably/lib/object/message/ObjectDelete.java create mode 100644 lib/src/main/java/io/ably/lib/object/message/ObjectMessage.java create mode 100644 lib/src/main/java/io/ably/lib/object/message/ObjectOperation.java create mode 100644 lib/src/main/java/io/ably/lib/object/message/ObjectOperationAction.java create mode 100644 lib/src/main/java/io/ably/lib/object/message/ObjectsMapEntry.java create mode 100644 lib/src/main/java/io/ably/lib/object/message/ObjectsMapSemantics.java create mode 100644 lib/src/main/java/io/ably/lib/object/message/package-info.java create mode 100644 lib/src/main/java/io/ably/lib/object/package-info.java create mode 100644 lib/src/main/java/io/ably/lib/object/path/PathObjectListener.java create mode 100644 lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionEvent.java create mode 100644 lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionOptions.java create mode 100644 lib/src/main/java/io/ably/lib/object/path/package-info.java create mode 100644 lib/src/main/java/io/ably/lib/object/path/types/package-info.java create mode 100644 lib/src/main/java/io/ably/lib/object/value/LiveCounter.java create mode 100644 lib/src/main/java/io/ably/lib/object/value/LiveMap.java create mode 100644 lib/src/main/java/io/ably/lib/object/value/LiveMapValue.java create mode 100644 lib/src/main/java/io/ably/lib/object/value/package-info.java diff --git a/lib/src/main/java/io/ably/lib/object/Subscription.java b/lib/src/main/java/io/ably/lib/object/Subscription.java new file mode 100644 index 000000000..0f74a907e --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/Subscription.java @@ -0,0 +1,30 @@ +package io.ably.lib.object; + +/** + * Represents a registration for receiving events from a subscribe operation. + * Provides a way to clean up and remove a subscription when it is no longer + * needed. + * + *

Example usage: + *

+ * {@code
+ * Subscription s = pathObject.subscribe(event -> { ... });
+ * // Later, when done with the subscription
+ * s.unsubscribe();
+ * }
+ * 
+ * + *

Spec: SUB1 + */ +public interface Subscription { + + /** + * Deregisters the listener that was registered by the corresponding + * {@code subscribe} call. Once called, the listener will not be invoked for + * any subsequent events and references to it are cleaned up. Calling this + * method more than once is a no-op. + * + *

Spec: SUB2a, SUB2b + */ + void unsubscribe(); +} diff --git a/lib/src/main/java/io/ably/lib/object/ValueType.java b/lib/src/main/java/io/ably/lib/object/ValueType.java index 4f1cb59a5..c045a075c 100644 --- a/lib/src/main/java/io/ably/lib/object/ValueType.java +++ b/lib/src/main/java/io/ably/lib/object/ValueType.java @@ -1,13 +1,28 @@ package io.ably.lib.object; +/** + * The type of a value resolved by a {@code PathObject} or wrapped by an + * {@code Instance} in the LiveObjects graph. + * + *

Spec: RTTS2 + */ public enum ValueType { + /** Corresponds to the {@code String} primitive. Spec: RTTS2a1 */ STRING, + /** Corresponds to the {@code Number} primitive. Spec: RTTS2a2 */ NUMBER, + /** Corresponds to the {@code Boolean} primitive. Spec: RTTS2a3 */ BOOLEAN, + /** Corresponds to the {@code Binary} primitive. Spec: RTTS2a4 */ BINARY, + /** Corresponds to the {@code JsonObject} primitive. Spec: RTTS2a5 */ JSON_OBJECT, + /** Corresponds to the {@code JsonArray} primitive. Spec: RTTS2a6 */ JSON_ARRAY, + /** Corresponds to a {@code LiveMap} object. Spec: RTTS2a7 */ LIVE_MAP, + /** Corresponds to a {@code LiveCounter} object. Spec: RTTS2a8 */ LIVE_COUNTER, + /** Returned when path resolution fails or the resolved value has none of the known types; never produced by an {@code Instance} in normal operation. Spec: RTTS2a9 */ UNKNOWN, } diff --git a/lib/src/main/java/io/ably/lib/object/instance/Instance.java b/lib/src/main/java/io/ably/lib/object/instance/Instance.java index c52a73d11..e2c9cbed3 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/Instance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/Instance.java @@ -10,23 +10,28 @@ import io.ably.lib.object.instance.types.LiveMapInstance; import io.ably.lib.object.instance.types.NumberInstance; import io.ably.lib.object.instance.types.StringInstance; -import io.ably.lib.objects.ObjectsSubscription; -import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; /** - * A direct-reference view of a single LiveObject (a {@code LiveMap} or {@code LiveCounter}) - * or a primitive value. Unlike {@code PathObject}, which resolves a path lazily against - * the LiveObjects graph at every call, an {@code Instance} is bound to a specific - * underlying value and dereferenced in O(1). + * A direct-reference view of a single resolved LiveObject ({@code LiveMap} or + * {@code LiveCounter}) or primitive value. * - *

Java exposes type-specific sub-types ({@link LiveMapInstance}, - * {@link LiveCounterInstance}, and the primitive {@code *Instance} types). Use the - * {@code as*} helpers to obtain a sub-type wrapper without performing type validation. - * Only {@link LiveMapInstance} and {@link LiveCounterInstance} expose an object id - * (via their own {@code getId()} methods); primitive instances are anonymous. + *

Unlike {@code PathObject}, which re-resolves its path on every call, an + * {@code Instance} is identity-addressed: it is bound to a specific underlying value + * and dereferenced in O(1), regardless of where that value sits in the graph. Read + * operations validate the access API preconditions and fail with an + * {@code AblyException} if they are not satisfied. * - *

Spec: RTINS1 + *

This base type exposes only the methods whose behaviour is independent of the + * wrapped type; everything else - including {@code subscribe} (RTTS7b) - is + * partitioned onto the sub-types. Use the {@code as*} helpers to obtain a sub-type + * view without type validation, or discriminate via {@link #getType()}. + * + *

Spec: RTINS1, RTTS7 + * + * @see LiveMapInstance + * @see LiveCounterInstance + * @see InstanceListener */ public interface Instance { @@ -34,6 +39,11 @@ public interface Instance { * Returns the {@link ValueType} of the value wrapped by this instance. Use this * instead of dedicated {@code isLiveMap}/{@code isLiveCounter}/etc. checks. * + *

An {@code Instance} is always constructed from a resolved value, so this never + * returns {@link ValueType#UNKNOWN} in normal operation. + * + *

Spec: RTTS8a + * * @return the wrapped value type */ @NotNull ValueType getType(); @@ -45,31 +55,15 @@ public interface Instance { * always bound to a resolved value, so this always returns a non-null result; * failures of the access API preconditions are signalled via {@code AblyException}. * - *

Spec: RTINS11 + *

Spec: RTINS11 / RTINS11c (universal non-null invariant - Instance is bound + * to an already-resolved value, so the path-resolution failure mode of + * PathObject#compactJson does not apply) / RTTS7a (typed-SDK signature reflects + * the universal invariant) * * @return the compacted JSON snapshot */ @NotNull JsonElement compactJson(); - /** - * Subscribes a listener for updates on the underlying LiveObject. The listener is - * invoked whenever the wrapped object is changed by a local or remote operation. - * Call {@link ObjectsSubscription#unsubscribe()} on the returned handle to stop - * receiving events for this listener. - * - *

Subscribe is not supported on primitive instances; implementations may throw - * when called on {@link NumberInstance}, {@link StringInstance}, - * {@link BooleanInstance}, {@link BinaryInstance}, {@link JsonObjectInstance} or - * {@link JsonArrayInstance}. - * - *

Spec: RTINS16 - * - * @param listener the listener to invoke on updates - * @return a subscription handle that can be used to unsubscribe this listener - */ - @NonBlocking - @NotNull ObjectsSubscription subscribe(@NotNull Listener listener); - /** * Returns this instance wrapped as a {@link LiveMapInstance}. * @@ -77,6 +71,8 @@ public interface Instance { * the returned wrapper are always permitted; write/terminal operations will fail * at call time if the wrapped value is not a {@code LiveMap}. * + *

Spec: RTTS9a + * * @return a {@link LiveMapInstance} view of this instance */ @NotNull LiveMapInstance asLiveMap(); @@ -85,6 +81,8 @@ public interface Instance { * Returns this instance wrapped as a {@link LiveCounterInstance}. * Best-effort cast; does not validate the underlying type. * + *

Spec: RTTS9b + * * @return a {@link LiveCounterInstance} view of this instance */ @NotNull LiveCounterInstance asLiveCounter(); @@ -93,6 +91,8 @@ public interface Instance { * Returns this instance wrapped as a {@link NumberInstance}. * Best-effort cast; does not validate the underlying type. * + *

Spec: RTTS9c + * * @return a {@link NumberInstance} view of this instance */ @NotNull NumberInstance asNumber(); @@ -101,6 +101,8 @@ public interface Instance { * Returns this instance wrapped as a {@link StringInstance}. * Best-effort cast; does not validate the underlying type. * + *

Spec: RTTS9c + * * @return a {@link StringInstance} view of this instance */ @NotNull StringInstance asString(); @@ -109,6 +111,8 @@ public interface Instance { * Returns this instance wrapped as a {@link BooleanInstance}. * Best-effort cast; does not validate the underlying type. * + *

Spec: RTTS9c + * * @return a {@link BooleanInstance} view of this instance */ @NotNull BooleanInstance asBoolean(); @@ -117,6 +121,8 @@ public interface Instance { * Returns this instance wrapped as a {@link BinaryInstance}. * Best-effort cast; does not validate the underlying type. * + *

Spec: RTTS9c + * * @return a {@link BinaryInstance} view of this instance */ @NotNull BinaryInstance asBinary(); @@ -125,6 +131,8 @@ public interface Instance { * Returns this instance wrapped as a {@link JsonObjectInstance}. * Best-effort cast; does not validate the underlying type. * + *

Spec: RTTS9c + * * @return a {@link JsonObjectInstance} view of this instance */ @NotNull JsonObjectInstance asJsonObject(); @@ -133,37 +141,9 @@ public interface Instance { * Returns this instance wrapped as a {@link JsonArrayInstance}. * Best-effort cast; does not validate the underlying type. * + *

Spec: RTTS9c + * * @return a {@link JsonArrayInstance} view of this instance */ @NotNull JsonArrayInstance asJsonArray(); - - /** - * Listener interface for {@link Instance#subscribe(Listener) instance - * subscriptions}. - * - *

Spec: RTINS16a1 - */ - interface Listener { - /** - * Invoked when the wrapped LiveObject is modified. - * - * @param event the event describing the change - */ - void onUpdated(@NotNull SubscriptionEvent event); - } - - /** - * Event delivered to {@link Listener#onUpdated(SubscriptionEvent)} when the wrapped - * LiveObject is updated. - * - *

Spec: RTINS16e - */ - interface SubscriptionEvent { - /** - * Returns the {@link Instance} that was updated. - * - * @return the updated instance - */ - @NotNull Instance getInstance(); - } } diff --git a/lib/src/main/java/io/ably/lib/object/instance/InstanceListener.java b/lib/src/main/java/io/ably/lib/object/instance/InstanceListener.java new file mode 100644 index 000000000..fe069e7db --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/InstanceListener.java @@ -0,0 +1,22 @@ +package io.ably.lib.object.instance; + +import io.ably.lib.object.instance.types.LiveCounterInstance; +import io.ably.lib.object.instance.types.LiveMapInstance; +import org.jetbrains.annotations.NotNull; + +/** + * Listener interface for instance subscriptions created via + * {@link LiveMapInstance#subscribe(InstanceListener)} or + * {@link LiveCounterInstance#subscribe(InstanceListener)}. + * + *

Spec: RTINS16a1 + */ +public interface InstanceListener { + + /** + * Invoked when the wrapped LiveObject is modified. + * + * @param event the event describing the change + */ + void onUpdated(@NotNull InstanceSubscriptionEvent event); +} diff --git a/lib/src/main/java/io/ably/lib/object/instance/InstanceSubscriptionEvent.java b/lib/src/main/java/io/ably/lib/object/instance/InstanceSubscriptionEvent.java new file mode 100644 index 000000000..c87526a9b --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/InstanceSubscriptionEvent.java @@ -0,0 +1,37 @@ +package io.ably.lib.object.instance; + +import io.ably.lib.object.instance.types.LiveCounterInstance; +import io.ably.lib.object.instance.types.LiveMapInstance; +import io.ably.lib.object.message.ObjectMessage; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Event delivered to {@link InstanceListener#onUpdated(InstanceSubscriptionEvent)} when + * the LiveObject wrapped by a subscribed {@link LiveMapInstance} or + * {@link LiveCounterInstance} is updated. + * + *

Spec: RTINS16e + */ +public interface InstanceSubscriptionEvent { + + /** + * Returns an {@link Instance} wrapping the LiveObject that was updated. + * + *

Spec: RTINS16e1 + * + * @return the updated instance + */ + @NotNull Instance getObject(); + + /** + * Returns the {@link ObjectMessage} describing the operation that caused this + * event, if any. The value is present whenever the underlying update carried an + * object message with an operation; otherwise it is {@code null}. + * + *

Spec: RTINS16e2 / PAOM1 + * + * @return the source {@code ObjectMessage}, or {@code null} if unavailable + */ + @Nullable ObjectMessage getMessage(); +} diff --git a/lib/src/main/java/io/ably/lib/object/instance/package-info.java b/lib/src/main/java/io/ably/lib/object/instance/package-info.java new file mode 100644 index 000000000..c99b3f05f --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/package-info.java @@ -0,0 +1,12 @@ +/** + * The identity-addressed view of the LiveObjects graph. + * {@link io.ably.lib.object.instance.Instance} wraps a specific resolved + * LiveObject or primitive value and dereferences it in O(1), following the + * object wherever it sits in the graph. Type-specific operations live on the + * sub-types in {@link io.ably.lib.object.instance.types}; instance + * subscriptions use {@link io.ably.lib.object.instance.InstanceListener} and + * {@link io.ably.lib.object.instance.InstanceSubscriptionEvent}. + * + *

Spec: RTINS1-RTINS16, RTTS7-RTTS9 + */ +package io.ably.lib.object.instance; diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java index 64aa8ba31..91e8b7023 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java @@ -5,15 +5,19 @@ /** * A read-only {@link Instance} bound to a binary primitive value - * (a {@code byte[]}). Primitive instances are anonymous (no object id) and do not - * support subscribe. + * (a {@code byte[]}). + * Primitive instances are anonymous (no object id) and deliberately do not expose + * {@code subscribe}, {@code set}, {@code remove} or any other id/iteration/write + * methods - only {@code value()} - per RTTS10c. + * + *

Spec: RTTS10c */ public interface BinaryInstance extends Instance { /** * Returns the wrapped binary value. * - *

Spec: RTINS4 + *

Spec: RTINS4 / RTTS10c * * @return the wrapped bytes */ diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java index b8516fda6..c4ec1a01e 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java @@ -5,14 +5,18 @@ /** * A read-only {@link Instance} bound to a {@code Boolean} primitive value. - * Primitive instances are anonymous (no object id) and do not support subscribe. + * Primitive instances are anonymous (no object id) and deliberately do not expose + * {@code subscribe}, {@code set}, {@code remove} or any other id/iteration/write + * methods - only {@code value()} - per RTTS10c. + * + *

Spec: RTTS10c */ public interface BooleanInstance extends Instance { /** * Returns the wrapped boolean. * - *

Spec: RTINS4 + *

Spec: RTINS4 / RTTS10c * * @return the wrapped boolean value */ diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java index b04c42da0..f85fc0865 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java @@ -6,14 +6,18 @@ /** * A read-only {@link Instance} bound to a {@link JsonArray} primitive value. - * Primitive instances are anonymous (no object id) and do not support subscribe. + * Primitive instances are anonymous (no object id) and deliberately do not expose + * {@code subscribe}, {@code set}, {@code remove} or any other id/iteration/write + * methods - only {@code value()} - per RTTS10c. + * + *

Spec: RTTS10c */ public interface JsonArrayInstance extends Instance { /** * Returns the wrapped JSON array. * - *

Spec: RTINS4 + *

Spec: RTINS4 / RTTS10c * * @return the wrapped JsonArray value */ diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java index 6c0254a46..7fce7183d 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java @@ -6,14 +6,18 @@ /** * A read-only {@link Instance} bound to a {@link JsonObject} primitive value. - * Primitive instances are anonymous (no object id) and do not support subscribe. + * Primitive instances are anonymous (no object id) and deliberately do not expose + * {@code subscribe}, {@code set}, {@code remove} or any other id/iteration/write + * methods - only {@code value()} - per RTTS10c. + * + *

Spec: RTTS10c */ public interface JsonObjectInstance extends Instance { /** * Returns the wrapped JSON object. * - *

Spec: RTINS4 + *

Spec: RTINS4 / RTTS10c * * @return the wrapped JsonObject value */ diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java index a63b0f2fb..c80b91f91 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java @@ -1,6 +1,9 @@ package io.ably.lib.object.instance.types; import io.ably.lib.object.instance.Instance; +import io.ably.lib.object.instance.InstanceListener; +import io.ably.lib.object.Subscription; +import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; import java.util.concurrent.CompletableFuture; @@ -9,6 +12,8 @@ * A {@link Instance} bound to a {@code LiveCounter}. Provides type-safe * access to counter operations such as {@link #value()}, {@link #increment(Number)} * and {@link #decrement(Number)}. + * + *

Spec: RTTS10b */ public interface LiveCounterInstance extends Instance { @@ -79,4 +84,21 @@ public interface LiveCounterInstance extends Instance { */ @NotNull CompletableFuture decrement(@NotNull Number amount); + + /** + * Subscribes a listener for updates on the wrapped {@code LiveCounter}. The + * listener is invoked whenever the wrapped counter is changed by a local or remote + * operation. Call {@link Subscription#unsubscribe()} on the returned handle + * to stop receiving events for this listener. + * + *

The subscription is identity-based: it follows the specific underlying + * {@code LiveCounter}, regardless of where it sits in the LiveObjects graph. + * + *

Spec: RTTS10b / RTINS16 + * + * @param listener the listener to invoke on updates + * @return a subscription handle that can be used to unsubscribe this listener + */ + @NonBlocking + @NotNull Subscription subscribe(@NotNull InstanceListener listener); } diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java index c9e46df34..a6c3fb2d4 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java @@ -1,7 +1,10 @@ package io.ably.lib.object.instance.types; import io.ably.lib.object.instance.Instance; -import io.ably.lib.objects.type.map.LiveMapValue; +import io.ably.lib.object.instance.InstanceListener; +import io.ably.lib.object.Subscription; +import io.ably.lib.object.value.LiveMapValue; +import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; @@ -16,6 +19,8 @@ * *

Operations are bound to the specific underlying {@code LiveMap}, dereferenced in * O(1), and do not perform any path resolution. + * + *

Spec: RTTS10a */ public interface LiveMapInstance extends Instance { @@ -112,4 +117,21 @@ public interface LiveMapInstance extends Instance { */ @NotNull CompletableFuture remove(@NotNull String key); + + /** + * Subscribes a listener for updates on the wrapped {@code LiveMap}. The listener is + * invoked whenever the wrapped map is changed by a local or remote operation. Call + * {@link Subscription#unsubscribe()} on the returned handle to stop + * receiving events for this listener. + * + *

The subscription is identity-based: it follows the specific underlying + * {@code LiveMap}, regardless of where it sits in the LiveObjects graph. + * + *

Spec: RTTS10a / RTINS16 + * + * @param listener the listener to invoke on updates + * @return a subscription handle that can be used to unsubscribe this listener + */ + @NonBlocking + @NotNull Subscription subscribe(@NotNull InstanceListener listener); } diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java index 3ff4a4041..4e94637f5 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java @@ -5,14 +5,18 @@ /** * A read-only {@link Instance} bound to a {@code Number} primitive value. - * Primitive instances are anonymous (no object id) and do not support subscribe. + * Primitive instances are anonymous (no object id) and deliberately do not expose + * {@code subscribe}, {@code set}, {@code remove} or any other id/iteration/write + * methods - only {@code value()} - per RTTS10c. + * + *

Spec: RTTS10c */ public interface NumberInstance extends Instance { /** * Returns the wrapped number. * - *

Spec: RTINS4 + *

Spec: RTINS4 / RTTS10c * * @return the wrapped numeric value */ diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java index 9b4a41104..06e39a417 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java @@ -5,14 +5,18 @@ /** * A read-only {@link Instance} bound to a {@code String} primitive value. - * Primitive instances are anonymous (no object id) and do not support subscribe. + * Primitive instances are anonymous (no object id) and deliberately do not expose + * {@code subscribe}, {@code set}, {@code remove} or any other id/iteration/write + * methods - only {@code value()} - per RTTS10c. + * + *

Spec: RTTS10c */ public interface StringInstance extends Instance { /** * Returns the wrapped string. * - *

Spec: RTINS4 + *

Spec: RTINS4 / RTTS10c * * @return the wrapped string value */ diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/package-info.java b/lib/src/main/java/io/ably/lib/object/instance/types/package-info.java new file mode 100644 index 000000000..2ec45e8fd --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/types/package-info.java @@ -0,0 +1,11 @@ +/** + * Type-specific {@code Instance} sub-types: the typed-SDK partition of instance + * operations. {@link io.ably.lib.object.instance.types.LiveMapInstance} + * (RTTS10a) carries map reads, writes and subscribe, + * {@link io.ably.lib.object.instance.types.LiveCounterInstance} (RTTS10b) + * carries counter operations and subscribe, and the six primitive sub-types + * (RTTS10c) expose only a type-narrowed, non-null {@code value()}. + * + *

Spec: RTTS10 + */ +package io.ably.lib.object.instance.types; diff --git a/lib/src/main/java/io/ably/lib/object/message/CounterCreate.java b/lib/src/main/java/io/ably/lib/object/message/CounterCreate.java new file mode 100644 index 000000000..2d8f5a203 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/message/CounterCreate.java @@ -0,0 +1,21 @@ +package io.ably.lib.object.message; + +import org.jetbrains.annotations.NotNull; + +/** + * Payload of a {@link ObjectOperationAction#COUNTER_CREATE} operation, describing the + * initial state of the created {@code LiveCounter} object. + * + *

Spec: CCR* + */ +public interface CounterCreate { + + /** + * Returns the initial value of the created counter object. + * + *

Spec: CCR2a + * + * @return the initial counter value + */ + @NotNull Double getCount(); +} diff --git a/lib/src/main/java/io/ably/lib/object/message/CounterInc.java b/lib/src/main/java/io/ably/lib/object/message/CounterInc.java new file mode 100644 index 000000000..fa1eeee82 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/message/CounterInc.java @@ -0,0 +1,22 @@ +package io.ably.lib.object.message; + +import org.jetbrains.annotations.NotNull; + +/** + * Payload of a {@link ObjectOperationAction#COUNTER_INC} operation, describing an amount + * by which a {@code LiveCounter} object is incremented. The amount may be negative, + * representing a decrement. + * + *

Spec: CIN* + */ +public interface CounterInc { + + /** + * Returns the amount by which the counter is incremented. + * + *

Spec: CIN2a + * + * @return the increment amount (may be negative) + */ + @NotNull Double getNumber(); +} diff --git a/lib/src/main/java/io/ably/lib/object/message/MapClear.java b/lib/src/main/java/io/ably/lib/object/message/MapClear.java new file mode 100644 index 000000000..28609f247 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/message/MapClear.java @@ -0,0 +1,12 @@ +package io.ably.lib.object.message; + +/** + * Payload of a {@link ObjectOperationAction#MAP_CLEAR} operation. This type + * deliberately has no attributes (MCL2) - the + * {@link ObjectOperation#getAction() action} and + * {@link ObjectOperation#getObjectId() objectId} are sufficient to describe the clear. + * + *

Spec: MCL1, MCL2 + */ +public interface MapClear { +} diff --git a/lib/src/main/java/io/ably/lib/object/message/MapCreate.java b/lib/src/main/java/io/ably/lib/object/message/MapCreate.java new file mode 100644 index 000000000..73103a92f --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/message/MapCreate.java @@ -0,0 +1,33 @@ +package io.ably.lib.object.message; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.Map; + +/** + * Payload of a {@link ObjectOperationAction#MAP_CREATE} operation, describing the + * initial state of the created {@code LiveMap} object. + * + *

Spec: MCR* + */ +public interface MapCreate { + + /** + * Returns the conflict-resolution semantics used by the created map object. + * + *

Spec: MCR2a + * + * @return the map semantics + */ + @NotNull ObjectsMapSemantics getSemantics(); + + /** + * Returns the initial entries of the created map object, indexed by key. + * + *

Spec: MCR2b + * + * @return an unmodifiable map of initial entries + */ + @NotNull @Unmodifiable Map getEntries(); +} diff --git a/lib/src/main/java/io/ably/lib/object/message/MapRemove.java b/lib/src/main/java/io/ably/lib/object/message/MapRemove.java new file mode 100644 index 000000000..51336eb5c --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/message/MapRemove.java @@ -0,0 +1,21 @@ +package io.ably.lib.object.message; + +import org.jetbrains.annotations.NotNull; + +/** + * Payload of a {@link ObjectOperationAction#MAP_REMOVE} operation, describing a key + * being removed from a {@code LiveMap} object. + * + *

Spec: MRM* + */ +public interface MapRemove { + + /** + * Returns the key being removed. + * + *

Spec: MRM2a + * + * @return the map key + */ + @NotNull String getKey(); +} diff --git a/lib/src/main/java/io/ably/lib/object/message/MapSet.java b/lib/src/main/java/io/ably/lib/object/message/MapSet.java new file mode 100644 index 000000000..742b5290f --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/message/MapSet.java @@ -0,0 +1,30 @@ +package io.ably.lib.object.message; + +import org.jetbrains.annotations.NotNull; + +/** + * Payload of a {@link ObjectOperationAction#MAP_SET} operation, describing a key being + * set on a {@code LiveMap} object. + * + *

Spec: MST* + */ +public interface MapSet { + + /** + * Returns the key being set. + * + *

Spec: MST2a + * + * @return the map key + */ + @NotNull String getKey(); + + /** + * Returns the value the key is being set to. + * + *

Spec: MST2b + * + * @return the value being set + */ + @NotNull ObjectData getValue(); +} diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectData.java b/lib/src/main/java/io/ably/lib/object/message/ObjectData.java new file mode 100644 index 000000000..72d2b690c --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/message/ObjectData.java @@ -0,0 +1,70 @@ +package io.ably.lib.object.message; + +import com.google.gson.JsonElement; +import org.jetbrains.annotations.Nullable; + +/** + * Represents a value in an object on a channel. A value is either a reference to another + * object ({@link #getObjectId()}) or exactly one of the primitive payloads + * ({@link #getString()}, {@link #getNumber()}, {@link #getBoolean()}, + * {@link #getBytes()}, {@link #getJson()}). + * + *

Spec: OD1 + */ +public interface ObjectData { + + /** + * Returns a reference to another object, used to support composable object + * structures. + * + *

Spec: OD2a + * + * @return the referenced object id, or {@code null} if this value is a primitive + */ + @Nullable String getObjectId(); + + /** + * Returns the string value. + * + *

Spec: OD2c + * + * @return the string value, or {@code null} if not applicable + */ + @Nullable String getString(); + + /** + * Returns the numeric value. + * + *

Spec: OD2c + * + * @return the numeric value, or {@code null} if not applicable + */ + @Nullable Double getNumber(); + + /** + * Returns the boolean value. + * + *

Spec: OD2c + * + * @return the boolean value, or {@code null} if not applicable + */ + @Nullable Boolean getBoolean(); + + /** + * Returns the binary value. + * + *

Spec: OD2c + * + * @return the binary value, or {@code null} if not applicable + */ + byte @Nullable [] getBytes(); + + /** + * Returns the JSON object or array value. + * + *

Spec: OD2c + * + * @return the JSON value, or {@code null} if not applicable + */ + @Nullable JsonElement getJson(); +} diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectDelete.java b/lib/src/main/java/io/ably/lib/object/message/ObjectDelete.java new file mode 100644 index 000000000..2ebd52cfa --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/message/ObjectDelete.java @@ -0,0 +1,13 @@ +package io.ably.lib.object.message; + +/** + * Payload of an {@link ObjectOperationAction#OBJECT_DELETE} operation. This type + * deliberately has no attributes (ODE2) - the + * {@link ObjectOperation#getAction() action} and + * {@link ObjectOperation#getObjectId() objectId} are sufficient to describe the + * deletion. + * + *

Spec: ODE1, ODE2 + */ +public interface ObjectDelete { +} diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectMessage.java b/lib/src/main/java/io/ably/lib/object/message/ObjectMessage.java new file mode 100644 index 000000000..36b3f825d --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/message/ObjectMessage.java @@ -0,0 +1,135 @@ +package io.ably.lib.object.message; + +import com.google.gson.JsonObject; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * The user-facing representation of an inbound object message that carried an operation. + * It is delivered to subscription listeners (see + * {@link io.ably.lib.object.path.PathObjectSubscriptionEvent} and + * {@link io.ably.lib.object.instance.InstanceSubscriptionEvent}) so that user code can + * inspect the metadata of the message that triggered an object change. + * + *

An {@code ObjectMessage} always carries an {@link #getOperation() operation}; object + * messages without an operation (e.g. sync state messages) are never surfaced to users. + * + *

This type is the entry point of the {@code io.ably.lib.object.message} package; + * all sibling types are reached by walking its properties: + * + *

{@code
+ * ObjectMessage
+ * └── getOperation()  → ObjectOperation
+ *     ├── getAction()        → ObjectOperationAction (enum)
+ *     ├── getMapCreate()     → MapCreate → ObjectsMapSemantics, Map → ObjectData
+ *     ├── getMapSet()        → MapSet → ObjectData
+ *     ├── getMapRemove()     → MapRemove
+ *     ├── getCounterCreate() → CounterCreate
+ *     ├── getCounterInc()    → CounterInc
+ *     ├── getObjectDelete()  → ObjectDelete (empty)
+ *     └── getMapClear()      → MapClear (empty)
+ * }
+ * + *

Spec: PAOM1, PAOM2 + */ +public interface ObjectMessage { + + /** + * Returns the unique id of the source object message. + * + *

Spec: PAOM2a / OM2a + * + * @return the message id, or {@code null} if unavailable + */ + @Nullable String getId(); + + /** + * Returns the client id of the client that published the source object message. + * + *

Spec: PAOM2b / OM2b + * + * @return the client id, or {@code null} if unavailable + */ + @Nullable String getClientId(); + + /** + * Returns the connection id of the connection from which the source object message + * was published. + * + *

Spec: PAOM2c / OM2c + * + * @return the connection id, or {@code null} if unavailable + */ + @Nullable String getConnectionId(); + + /** + * Returns the timestamp of the source object message, as milliseconds since the + * epoch. + * + *

Spec: PAOM2d / OM2e + * + * @return the timestamp in milliseconds since the epoch, or {@code null} if + * unavailable + */ + @Nullable Long getTimestamp(); + + /** + * Returns the name of the channel on which the source object message was received. + * + *

Spec: PAOM2e + * + * @return the channel name + */ + @NotNull String getChannel(); + + /** + * Returns the operation carried by the source object message. + * + *

Spec: PAOM2f + * + * @return the operation that was applied + */ + @NotNull ObjectOperation getOperation(); + + /** + * Returns the serial of the source object message - an opaque string that uniquely + * identifies the operation. + * + *

Spec: PAOM2g / OM2h + * + * @return the serial, or {@code null} if unavailable + */ + @Nullable String getSerial(); + + /** + * Returns the timestamp derived from the {@link #getSerial() serial} of the source + * object message, as milliseconds since the epoch. + * + *

Spec: PAOM2h / OM2j + * + * @return the serial timestamp in milliseconds since the epoch, or {@code null} if + * unavailable + */ + @Nullable Long getSerialTimestamp(); + + /** + * Returns the site code of the source object message - an opaque string used as a + * key to update the map of serial values on an object. + * + *

Spec: PAOM2i / OM2i + * + * @return the site code, or {@code null} if unavailable + */ + @Nullable String getSiteCode(); + + /** + * Returns the extras of the source object message - a JSON-encodable object + * containing arbitrary message metadata and/or ancillary payloads. The client + * library treats this field opaquely. + * + *

Spec: PAOM2j / OM2d + * + * @return the extras, or {@code null} if unavailable + */ + @Nullable JsonObject getExtras(); +} diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectOperation.java b/lib/src/main/java/io/ably/lib/object/message/ObjectOperation.java new file mode 100644 index 000000000..52a2d2d1b --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/message/ObjectOperation.java @@ -0,0 +1,106 @@ +package io.ably.lib.object.message; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * The user-facing representation of an operation applied to an object on a channel. It + * is exposed as the {@link ObjectMessage#getOperation() operation} attribute of an + * {@link ObjectMessage}. + * + *

Exactly one of the payload accessors ({@link #getMapCreate()}, + * {@link #getMapSet()}, {@link #getMapRemove()}, {@link #getCounterCreate()}, + * {@link #getCounterInc()}, {@link #getObjectDelete()}, {@link #getMapClear()}) returns + * a non-null value, corresponding to the {@link #getAction() action} of the operation. + * + *

Note that, unlike the wire-level operation representation, this type does not carry + * the outbound-only {@code mapCreateWithObjectId} / {@code counterCreateWithObjectId} + * variants: those are resolved back to their derived {@link MapCreate} / + * {@link CounterCreate} forms before being surfaced to users. + * + *

Spec: PAOOP1, PAOOP2 + */ +public interface ObjectOperation { + + /** + * Returns the action of this operation, defining what was applied to the object. + * + *

Spec: PAOOP2a / OOP3a + * + * @return the operation action + */ + @NotNull ObjectOperationAction getAction(); + + /** + * Returns the object id of the object on the channel to which this operation was + * applied. + * + *

Spec: PAOOP2b / OOP3b + * + * @return the target object id + */ + @NotNull String getObjectId(); + + /** + * Returns the payload of a {@link ObjectOperationAction#MAP_CREATE} operation. + * + *

Spec: PAOOP2c / OOP3j + * + * @return the map-create payload, or {@code null} if not applicable + */ + @Nullable MapCreate getMapCreate(); + + /** + * Returns the payload of a {@link ObjectOperationAction#MAP_SET} operation. + * + *

Spec: PAOOP2d / OOP3k + * + * @return the map-set payload, or {@code null} if not applicable + */ + @Nullable MapSet getMapSet(); + + /** + * Returns the payload of a {@link ObjectOperationAction#MAP_REMOVE} operation. + * + *

Spec: PAOOP2e / OOP3l + * + * @return the map-remove payload, or {@code null} if not applicable + */ + @Nullable MapRemove getMapRemove(); + + /** + * Returns the payload of a {@link ObjectOperationAction#COUNTER_CREATE} operation. + * + *

Spec: PAOOP2f / OOP3m + * + * @return the counter-create payload, or {@code null} if not applicable + */ + @Nullable CounterCreate getCounterCreate(); + + /** + * Returns the payload of a {@link ObjectOperationAction#COUNTER_INC} operation. + * + *

Spec: PAOOP2g / OOP3n + * + * @return the counter-increment payload, or {@code null} if not applicable + */ + @Nullable CounterInc getCounterInc(); + + /** + * Returns the payload of an {@link ObjectOperationAction#OBJECT_DELETE} operation. + * + *

Spec: PAOOP2h / OOP3o + * + * @return the object-delete payload, or {@code null} if not applicable + */ + @Nullable ObjectDelete getObjectDelete(); + + /** + * Returns the payload of a {@link ObjectOperationAction#MAP_CLEAR} operation. + * + *

Spec: PAOOP2i / OOP3r + * + * @return the map-clear payload, or {@code null} if not applicable + */ + @Nullable MapClear getMapClear(); +} diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectOperationAction.java b/lib/src/main/java/io/ably/lib/object/message/ObjectOperationAction.java new file mode 100644 index 000000000..0d3730ea3 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/message/ObjectOperationAction.java @@ -0,0 +1,37 @@ +package io.ably.lib.object.message; + +/** + * The action of an {@link ObjectOperation}, defining the type of operation that was + * applied to an object on a channel. + * + *

Spec: OOP2 / PAOOP2a + */ +public enum ObjectOperationAction { + + /** Creates a new {@code LiveMap} object. Spec: OOP2 */ + MAP_CREATE, + + /** Sets the value at a key of a {@code LiveMap} object. Spec: OOP2 */ + MAP_SET, + + /** Removes a key from a {@code LiveMap} object. Spec: OOP2 */ + MAP_REMOVE, + + /** Creates a new {@code LiveCounter} object. Spec: OOP2 */ + COUNTER_CREATE, + + /** Increments the value of a {@code LiveCounter} object. Spec: OOP2 */ + COUNTER_INC, + + /** Deletes (tombstones) an object. Spec: OOP2 */ + OBJECT_DELETE, + + /** Removes all entries from a {@code LiveMap} object. Spec: OOP2 */ + MAP_CLEAR, + + /** + * Future-compatibility fallback for an action not recognized by this version of + * the client library. + */ + UNKNOWN, +} diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectsMapEntry.java b/lib/src/main/java/io/ably/lib/object/message/ObjectsMapEntry.java new file mode 100644 index 000000000..0da010f0a --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/message/ObjectsMapEntry.java @@ -0,0 +1,51 @@ +package io.ably.lib.object.message; + +import org.jetbrains.annotations.Nullable; + +/** + * Represents the value at a given key in a {@code LiveMap} object. + * + *

Spec: ME1 + */ +public interface ObjectsMapEntry { + + /** + * Indicates whether the map entry has been removed. + * + *

Spec: OME2a + * + * @return {@code true} if the entry is tombstoned, or {@code null} if unavailable + */ + @Nullable Boolean getTombstone(); + + /** + * Returns the serial value of the latest operation that was applied to the map + * entry. + * + *

Spec: OME2b + * + * @return the entry timeserial, or {@code null} if unavailable + */ + @Nullable String getTimeserial(); + + /** + * Returns the timestamp derived from the {@link #getTimeserial() timeserial} of + * this entry, as milliseconds since the epoch. Only present if + * {@link #getTombstone()} is {@code true}. + * + *

Spec: OME2d + * + * @return the serial timestamp in milliseconds since the epoch, or {@code null} if + * unavailable + */ + @Nullable Long getSerialTimestamp(); + + /** + * Returns the data that represents the value of the map entry. + * + *

Spec: OME2c + * + * @return the entry value, or {@code null} if unavailable + */ + @Nullable ObjectData getData(); +} diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectsMapSemantics.java b/lib/src/main/java/io/ably/lib/object/message/ObjectsMapSemantics.java new file mode 100644 index 000000000..d5cae3f9b --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/message/ObjectsMapSemantics.java @@ -0,0 +1,18 @@ +package io.ably.lib.object.message; + +/** + * The conflict-resolution semantics used by a {@code LiveMap} object. + * + *

Spec: OMP2 + */ +public enum ObjectsMapSemantics { + + /** Last-write-wins conflict resolution. Spec: OMP2a */ + LWW, + + /** + * Future-compatibility fallback for semantics not known to this version of the + * client library. + */ + UNKNOWN, +} diff --git a/lib/src/main/java/io/ably/lib/object/message/package-info.java b/lib/src/main/java/io/ably/lib/object/message/package-info.java new file mode 100644 index 000000000..a90af7614 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/message/package-info.java @@ -0,0 +1,26 @@ +/** + * User-facing object message metadata, delivered to subscription listeners so + * that user code can inspect the operation that triggered an object change. + * + *

{@link io.ably.lib.object.message.ObjectMessage} is the single entry point + * of this package; every other type is reached by walking its properties: + * + *

{@code
+ * ObjectMessage                          (delivered in subscription events)
+ * └── getOperation()  → ObjectOperation
+ *     ├── getAction()        → ObjectOperationAction (enum)
+ *     ├── getMapCreate()     → MapCreate
+ *     │   ├── getSemantics() → ObjectsMapSemantics (enum)
+ *     │   └── getEntries()   → Map
+ *     │                          └── getData() → ObjectData
+ *     ├── getMapSet()        → MapSet ── getValue() → ObjectData
+ *     ├── getMapRemove()     → MapRemove
+ *     ├── getCounterCreate() → CounterCreate
+ *     ├── getCounterInc()    → CounterInc
+ *     ├── getObjectDelete()  → ObjectDelete (empty)
+ *     └── getMapClear()      → MapClear (empty)
+ * }
+ * + *

Spec: PAOM1-PAOM3, PAOOP1-PAOOP3 + */ +package io.ably.lib.object.message; diff --git a/lib/src/main/java/io/ably/lib/object/package-info.java b/lib/src/main/java/io/ably/lib/object/package-info.java new file mode 100644 index 000000000..2a8719347 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/package-info.java @@ -0,0 +1,17 @@ +/** + * The public, strongly-typed LiveObjects API: path-based and instance-based views + * over the objects graph on a channel. + * + *

This root package holds the types shared by both view hierarchies: + * {@link io.ably.lib.object.ValueType} (the categories a resolved value may have) + * and {@link io.ably.lib.object.Subscription} (the handle returned by every + * {@code subscribe} operation). The hierarchies themselves live in + * {@link io.ably.lib.object.path} (lazy, path-addressed references) and + * {@link io.ably.lib.object.instance} (O(1), identity-addressed references); + * message metadata delivered to subscription listeners lives in + * {@link io.ably.lib.object.message}, and write-side value types in + * {@link io.ably.lib.object.value}. + * + *

Spec: RTTS1-RTTS10 (typed-SDK public API partition) + */ +package io.ably.lib.object; diff --git a/lib/src/main/java/io/ably/lib/object/path/PathObject.java b/lib/src/main/java/io/ably/lib/object/path/PathObject.java index 6ef38a1c6..6a96de4ff 100644 --- a/lib/src/main/java/io/ably/lib/object/path/PathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/PathObject.java @@ -11,28 +11,34 @@ import io.ably.lib.object.path.types.LiveMapPathObject; import io.ably.lib.object.path.types.NumberPathObject; import io.ably.lib.object.path.types.StringPathObject; -import io.ably.lib.objects.ObjectsSubscription; +import io.ably.lib.object.Subscription; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; /** - * Provides a path-based, navigational view over the LiveObjects graph rooted at the - * channel's root {@code LiveMap}. A {@code PathObject} encapsulates a path expressed as - * an ordered list of string segments and resolves the path lazily against the current - * client-side state of the graph when read or write operations are invoked. + * A lazy, path-based reference into the LiveObjects graph rooted at the channel's root + * {@code LiveMap}. * - *

Resolution is best-effort: it observes the local object tree at the time the - * operation is called. There is no global transaction primitive, so the value at a given - * path can change between two calls on the same {@code PathObject} (e.g. between + *

A {@code PathObject} stores a path as an ordered list of string segments and + * resolves it against the local object graph each time a method is called. Resolution + * is best-effort: the value at a path may change between two calls (e.g. between * {@link #exists()} and a subsequent write) as updates from other clients are applied. + * Operations that resolve the path validate the access/write API preconditions and + * fail with an {@code AblyException} if they are not satisfied. * - *

For the strongly-typed flavour of the API in Java, callers normally interact with - * type-specific sub-types ({@link LiveMapPathObject}, {@link LiveCounterPathObject}, and - * the primitive {@code *PathObject} types). Use the {@code as*} helpers to obtain a - * sub-type wrapper without performing type validation. + *

This base type exposes only the methods whose behaviour is independent of the + * resolved type; map and counter reads/writes are partitioned onto the sub-types + * (RTTS3e). Use the {@code as*} helpers to obtain a sub-type view without type + * validation, e.g. {@code pathObject.asLiveMap().at("a.b.c")} (RTTS3g). The spec's + * {@code compact} is not exposed; {@link #compactJson()} is the supported equivalent + * (RTTS3f). * - *

Spec: RTPO1, RTPO2 + *

Spec: RTPO1, RTPO2, RTTS3 + * + * @see LiveMapPathObject + * @see LiveCounterPathObject + * @see PathObjectListener */ public interface PathObject { @@ -40,6 +46,11 @@ public interface PathObject { * Returns the {@link ValueType} of the value resolved at this path currently. * Use this instead of dedicated {@code isLiveMap}/{@code isLiveCounter}/etc. checks. * + *

Returns {@link ValueType#UNKNOWN} when the path does not resolve or the + * resolved value falls into none of the known categories. + * + *

Spec: RTTS4b + * * @return the resolved value type at this path */ @NotNull ValueType getType(); @@ -50,7 +61,7 @@ public interface PathObject { * path with segments {@code ["a", "b.c", "d"]} is represented as {@code "a.b\.c.d"}. * An empty path (i.e. the root {@code PathObject}) returns the empty string. * - *

Spec: RTPO4 + *

Spec: RTPO4 / RTTS3a * * @return the dot-delimited path from the root to this position */ @@ -64,7 +75,7 @@ public interface PathObject { * no object id), when the path does not resolve, or when called on primitive * {@code *PathObject} sub-types. * - *

Spec: RTPO8 + *

Spec: RTPO8 / RTTS3b * * @return a {@link Instance} wrapping the resolved live object, or {@code null} */ @@ -78,7 +89,7 @@ public interface PathObject { * *

Returns {@code null} when the path does not resolve. * - *

Spec: RTPO14 + *

Spec: RTPO14 / RTTS3c * * @return the compacted JSON snapshot, or {@code null} if the path does not resolve */ @@ -87,32 +98,32 @@ public interface PathObject { /** * Subscribes a listener for path-based update events. The listener is invoked when * an operation modifies the value at this path. The same path may be subscribed by - * multiple listeners independently. Call {@link ObjectsSubscription#unsubscribe()} + * multiple listeners independently. Call {@link Subscription#unsubscribe()} * on the returned handle to stop receiving events for this listener. * - *

Spec: RTPO19 + *

Spec: RTPO19 / RTTS3d * * @param listener the listener to invoke on updates * @return a subscription handle that can be used to unsubscribe this listener */ @NonBlocking - @NotNull ObjectsSubscription subscribe(@NotNull Listener listener); + @NotNull Subscription subscribe(@NotNull PathObjectListener listener); /** * Subscribes a listener for path-based update events using the provided - * {@link SubscriptionOptions}. Options control coverage rules such as the + * {@link PathObjectSubscriptionOptions}. Options control coverage rules such as the * {@code depth} of nested updates that trigger the listener. Call - * {@link ObjectsSubscription#unsubscribe()} on the returned handle to stop + * {@link Subscription#unsubscribe()} on the returned handle to stop * receiving events for this listener. * - *

Spec: RTPO19 + *

Spec: RTPO19 / RTTS3d * * @param listener the listener to invoke on updates * @param options optional subscription options, may be {@code null} * @return a subscription handle that can be used to unsubscribe this listener */ @NonBlocking - @NotNull ObjectsSubscription subscribe(@NotNull Listener listener, @Nullable SubscriptionOptions options); + @NotNull Subscription subscribe(@NotNull PathObjectListener listener, @Nullable PathObjectSubscriptionOptions options); /** * Returns {@code true} if a value currently resolves at this path in the local @@ -122,6 +133,8 @@ public interface PathObject { * *

Complexity is O(n) in the path length because the path must be resolved. * + *

Spec: RTTS4a + * * @return {@code true} if the path resolves to a value, {@code false} otherwise */ boolean exists(); @@ -134,6 +147,8 @@ public interface PathObject { * returned wrapper; write or terminal operations that require resolution will fail * at call time if the resolved value is not a {@code LiveMap}. * + *

Spec: RTTS5a + * * @return a {@link LiveMapPathObject} view of this path */ @NotNull LiveMapPathObject asLiveMap(); @@ -142,6 +157,8 @@ public interface PathObject { * Returns this {@code PathObject} wrapped as a {@link LiveCounterPathObject}. * Best-effort cast; does not validate the underlying type at this path. * + *

Spec: RTTS5b + * * @return a {@link LiveCounterPathObject} view of this path */ @NotNull LiveCounterPathObject asLiveCounter(); @@ -150,6 +167,8 @@ public interface PathObject { * Returns this {@code PathObject} wrapped as a {@link NumberPathObject}. * Best-effort cast; does not validate the underlying type at this path. * + *

Spec: RTTS5c + * * @return a {@link NumberPathObject} view of this path */ @NotNull NumberPathObject asNumber(); @@ -158,6 +177,8 @@ public interface PathObject { * Returns this {@code PathObject} wrapped as a {@link StringPathObject}. * Best-effort cast; does not validate the underlying type at this path. * + *

Spec: RTTS5c + * * @return a {@link StringPathObject} view of this path */ @NotNull StringPathObject asString(); @@ -166,6 +187,8 @@ public interface PathObject { * Returns this {@code PathObject} wrapped as a {@link BooleanPathObject}. * Best-effort cast; does not validate the underlying type at this path. * + *

Spec: RTTS5c + * * @return a {@link BooleanPathObject} view of this path */ @NotNull BooleanPathObject asBoolean(); @@ -174,6 +197,8 @@ public interface PathObject { * Returns this {@code PathObject} wrapped as a {@link BinaryPathObject}. * Best-effort cast; does not validate the underlying type at this path. * + *

Spec: RTTS5c + * * @return a {@link BinaryPathObject} view of this path */ @NotNull BinaryPathObject asBinary(); @@ -182,6 +207,8 @@ public interface PathObject { * Returns this {@code PathObject} wrapped as a {@link JsonObjectPathObject}. * Best-effort cast; does not validate the underlying type at this path. * + *

Spec: RTTS5c + * * @return a {@link JsonObjectPathObject} view of this path */ @NotNull JsonObjectPathObject asJsonObject(); @@ -190,72 +217,9 @@ public interface PathObject { * Returns this {@code PathObject} wrapped as a {@link JsonArrayPathObject}. * Best-effort cast; does not validate the underlying type at this path. * + *

Spec: RTTS5c + * * @return a {@link JsonArrayPathObject} view of this path */ @NotNull JsonArrayPathObject asJsonArray(); - - /** - * Listener interface for {@link PathObject#subscribe(Listener) path-based subscriptions}. - * - *

Spec: RTPO19a1 - */ - interface Listener { - /** - * Invoked when a change is applied at, or beneath, the subscribed path according - * to the configured {@link SubscriptionOptions}. - * - * @param event the event describing the change - */ - void onUpdated(@NotNull SubscriptionEvent event); - } - - /** - * Event delivered to {@link Listener#onUpdated(SubscriptionEvent)} when a change - * affects the subscribed path. - * - *

Spec: RTPO19e - */ - interface SubscriptionEvent { - /** - * Returns a {@link PathObject} pointing to the path where the change occurred. - * - *

Spec: RTPO19e1 - * - * @return the {@code PathObject} at the changed path - */ - @NotNull PathObject getObject(); - } - - /** - * Optional subscription options accepted by - * {@link PathObject#subscribe(Listener, SubscriptionOptions)}. - * - *

Spec: RTPO19c - */ - final class SubscriptionOptions { - - private final Integer depth; - - /** - * Creates options with the given {@code depth}. - * - * @param depth how many levels of path nesting below the subscribed path should - * trigger the listener; must be a positive integer if provided - */ - public SubscriptionOptions(@Nullable Integer depth) { - this.depth = depth; - } - - /** - * Returns the configured nesting depth, or {@code null} if not set. - * - *

Spec: RTPO19c1 - * - * @return the depth value, or {@code null} - */ - @Nullable - public Integer getDepth() { - return depth; - } - } } diff --git a/lib/src/main/java/io/ably/lib/object/path/PathObjectListener.java b/lib/src/main/java/io/ably/lib/object/path/PathObjectListener.java new file mode 100644 index 000000000..895e4ad2f --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/path/PathObjectListener.java @@ -0,0 +1,21 @@ +package io.ably.lib.object.path; + +import org.jetbrains.annotations.NotNull; + +/** + * Listener interface for path-based subscriptions created via + * {@link PathObject#subscribe(PathObjectListener)} or + * {@link PathObject#subscribe(PathObjectListener, PathObjectSubscriptionOptions)}. + * + *

Spec: RTPO19a1 + */ +public interface PathObjectListener { + + /** + * Invoked when a change is applied at, or beneath, the subscribed path according + * to the configured {@link PathObjectSubscriptionOptions}. + * + * @param event the event describing the change + */ + void onUpdated(@NotNull PathObjectSubscriptionEvent event); +} diff --git a/lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionEvent.java b/lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionEvent.java new file mode 100644 index 000000000..a8c753c70 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionEvent.java @@ -0,0 +1,34 @@ +package io.ably.lib.object.path; + +import io.ably.lib.object.message.ObjectMessage; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Event delivered to {@link PathObjectListener#onUpdated(PathObjectSubscriptionEvent)} + * when a change affects the subscribed path. + * + *

Spec: RTPO19e / RTTS3d + */ +public interface PathObjectSubscriptionEvent { + + /** + * Returns a {@link PathObject} pointing to the path where the change occurred. + * + *

Spec: RTPO19e1 + * + * @return the {@code PathObject} at the changed path + */ + @NotNull PathObject getObject(); + + /** + * Returns the {@link ObjectMessage} describing the operation that caused this + * event, if any. The value is present whenever the underlying update carried + * an object message with an operation; otherwise it is {@code null}. + * + *

Spec: RTPO19e2 / PAOM1 + * + * @return the source {@code ObjectMessage}, or {@code null} if unavailable + */ + @Nullable ObjectMessage getMessage(); +} diff --git a/lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionOptions.java b/lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionOptions.java new file mode 100644 index 000000000..c586d97d4 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionOptions.java @@ -0,0 +1,36 @@ +package io.ably.lib.object.path; + +import org.jetbrains.annotations.Nullable; + +/** + * Optional subscription options accepted by + * {@link PathObject#subscribe(PathObjectListener, PathObjectSubscriptionOptions)}. + * + *

Spec: RTPO19c + */ +public final class PathObjectSubscriptionOptions { + + private final Integer depth; + + /** + * Creates options with the given {@code depth}. + * + * @param depth how many levels of path nesting below the subscribed path should + * trigger the listener; must be a positive integer if provided + */ + public PathObjectSubscriptionOptions(@Nullable Integer depth) { + this.depth = depth; + } + + /** + * Returns the configured nesting depth, or {@code null} if not set. + * + *

Spec: RTPO19c1 + * + * @return the depth value, or {@code null} + */ + @Nullable + public Integer getDepth() { + return depth; + } +} diff --git a/lib/src/main/java/io/ably/lib/object/path/package-info.java b/lib/src/main/java/io/ably/lib/object/path/package-info.java new file mode 100644 index 000000000..a2414cf6c --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/path/package-info.java @@ -0,0 +1,13 @@ +/** + * The path-addressed view of the LiveObjects graph. + * {@link io.ably.lib.object.path.PathObject} stores a path from the channel's + * root {@code LiveMap} and re-resolves it lazily on every call, so a reference + * survives object replacement at its path. Type-specific operations live on the + * sub-types in {@link io.ably.lib.object.path.types}; path-based subscriptions + * use {@link io.ably.lib.object.path.PathObjectListener}, + * {@link io.ably.lib.object.path.PathObjectSubscriptionEvent} and + * {@link io.ably.lib.object.path.PathObjectSubscriptionOptions}. + * + *

Spec: RTPO1-RTPO19, RTTS3-RTTS5 + */ +package io.ably.lib.object.path; diff --git a/lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java index ce7b596dd..f47765cea 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java @@ -12,6 +12,8 @@ * via {@code at(...)} is not available here because it is only defined on * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are * useful here. + * + *

Spec: RTTS6c */ public interface BinaryPathObject extends PathObject { @@ -19,7 +21,7 @@ public interface BinaryPathObject extends PathObject { * Returns the binary value at this path, or {@code null} when the path does not * resolve or resolves to a non-binary value. * - *

Spec: RTPO7 + *

Spec: RTPO7 / RTTS6c * * @return the resolved bytes, or {@code null} */ diff --git a/lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java index 1fd62578e..b582227c8 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java @@ -11,6 +11,8 @@ * via {@code at(...)} is not available here because it is only defined on * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are * useful here. + * + *

Spec: RTTS6c */ public interface BooleanPathObject extends PathObject { @@ -18,7 +20,7 @@ public interface BooleanPathObject extends PathObject { * Returns the boolean at this path, or {@code null} when the path does not resolve * or resolves to a non-boolean value. * - *

Spec: RTPO7 + *

Spec: RTPO7 / RTTS6c * * @return the resolved boolean, or {@code null} */ diff --git a/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java index 28a97f3c7..af9bb9ad4 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java @@ -12,6 +12,8 @@ * via {@code at(...)} is not available here because it is only defined on * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are * useful here. + * + *

Spec: RTTS6c */ public interface JsonArrayPathObject extends PathObject { @@ -19,7 +21,7 @@ public interface JsonArrayPathObject extends PathObject { * Returns the JSON array at this path, or {@code null} when the path does not * resolve or resolves to a non-JsonArray value. * - *

Spec: RTPO7 + *

Spec: RTPO7 / RTTS6c * * @return the resolved JsonArray, or {@code null} */ diff --git a/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java index 0a2d70db0..c54897070 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java @@ -12,6 +12,8 @@ * via {@code at(...)} is not available here because it is only defined on * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are * useful here. + * + *

Spec: RTTS6c */ public interface JsonObjectPathObject extends PathObject { @@ -19,7 +21,7 @@ public interface JsonObjectPathObject extends PathObject { * Returns the JSON object at this path, or {@code null} when the path does not * resolve or resolves to a non-JsonObject value. * - *

Spec: RTPO7 + *

Spec: RTPO7 / RTTS6c * * @return the resolved JsonObject, or {@code null} */ diff --git a/lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java index dde18fca9..bb2588213 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java @@ -18,6 +18,8 @@ * return {@code null} when the path does not resolve to a {@code LiveCounter}; write * operations complete the returned {@link CompletableFuture} exceptionally with an * {@code AblyException} (status 400, code 92007) in that case. + * + *

Spec: RTTS6b */ public interface LiveCounterPathObject extends PathObject { diff --git a/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java index c52d3aa6c..11cbe4c4f 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java @@ -1,7 +1,7 @@ package io.ably.lib.object.path.types; import io.ably.lib.object.path.PathObject; -import io.ably.lib.objects.type.map.LiveMapValue; +import io.ably.lib.object.value.LiveMapValue; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; @@ -22,6 +22,8 @@ * not resolve to a {@code LiveMap}; write operations complete the returned * {@link CompletableFuture} exceptionally with an {@code AblyException} * (status 400, code 92007) in that case. + * + *

Spec: RTTS6a */ public interface LiveMapPathObject extends PathObject { diff --git a/lib/src/main/java/io/ably/lib/object/path/types/NumberPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/NumberPathObject.java index ca2c4a3c2..3903004fa 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/NumberPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/NumberPathObject.java @@ -11,6 +11,8 @@ * via {@code at(...)} is not available here because it is only defined on * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are * useful here. + * + *

Spec: RTTS6c */ public interface NumberPathObject extends PathObject { @@ -18,7 +20,7 @@ public interface NumberPathObject extends PathObject { * Returns the number at this path, or {@code null} when the path does not resolve * or resolves to a non-numeric value. * - *

Spec: RTPO7 + *

Spec: RTPO7 / RTTS6c * * @return the resolved number, or {@code null} */ diff --git a/lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java index d520168d2..06c332994 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java @@ -11,6 +11,8 @@ * via {@code at(...)} is not available here because it is only defined on * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are * useful here. + * + *

Spec: RTTS6c */ public interface StringPathObject extends PathObject { @@ -18,7 +20,7 @@ public interface StringPathObject extends PathObject { * Returns the string at this path, or {@code null} when the path does not resolve * or resolves to a non-string value. * - *

Spec: RTPO7 + *

Spec: RTPO7 / RTTS6c * * @return the resolved string, or {@code null} */ diff --git a/lib/src/main/java/io/ably/lib/object/path/types/package-info.java b/lib/src/main/java/io/ably/lib/object/path/types/package-info.java new file mode 100644 index 000000000..c97e152dc --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/path/types/package-info.java @@ -0,0 +1,11 @@ +/** + * Type-specific {@code PathObject} sub-types: the typed-SDK partition of path + * operations. {@link io.ably.lib.object.path.types.LiveMapPathObject} (RTTS6a) + * carries map navigation and writes, + * {@link io.ably.lib.object.path.types.LiveCounterPathObject} (RTTS6b) carries + * counter operations, and the six primitive sub-types (RTTS6c) expose only a + * type-narrowed {@code value()}. + * + *

Spec: RTTS6 + */ +package io.ably.lib.object.path.types; diff --git a/lib/src/main/java/io/ably/lib/object/value/LiveCounter.java b/lib/src/main/java/io/ably/lib/object/value/LiveCounter.java new file mode 100644 index 000000000..95f9e45b9 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/value/LiveCounter.java @@ -0,0 +1,72 @@ +package io.ably.lib.object.value; + +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.InvocationTargetException; + +/** + * An immutable value type representing the intent to create a new + * {@code LiveCounter} object with a specific initial count. Passed to mutation + * methods (such as {@code LiveMapInstance#set} or {@code LiveMapPathObject#set}, + * wrapped via {@link LiveMapValue#of(LiveCounter)}) to assign a new + * {@code LiveCounter} to the objects graph. + * + *

This type is a holder for the initial value only - it is not a live, + * subscribable view of channel state. The {@code COUNTER_CREATE} operation it + * gives rise to is published when the enclosing mutation is applied. + * + *

Instances are obtained via the static {@link #create(Number)} factory and + * are immutable after creation. The initial count is held internally by the + * implementation; it has no public accessor. + * + *

Spec: RTLCV1, RTLCV2, RTLCV3 + */ +public abstract class LiveCounter { + + private static final String IMPLEMENTATION_CLASS = "io.ably.lib.object.DefaultLiveCounter"; + + /** + * Extended by the LiveObjects implementation; not intended for + * application subclassing. Avoids implicit empty public constructor. + */ + protected LiveCounter() { + } + + /** + * Creates a new {@code LiveCounter} value type with an initial count of 0. + * + *

Spec: RTLCV3, RTLCV3a1, RTLCV3b + * + * @return an immutable {@code LiveCounter} value type + * @throws IllegalStateException if the LiveObjects plugin is not on the classpath + */ + @NotNull + public static LiveCounter create() { + return create(0); + } + + /** + * Creates a new {@code LiveCounter} value type with the given initial count. + * No input validation is performed at creation time; validation is deferred + * to when the value is evaluated by a mutation method. + * + *

Spec: RTLCV3, RTLCV3b, RTLCV3c, RTLCV3d + * + * @param initialCount the initial count for the new {@code LiveCounter} object + * @return an immutable {@code LiveCounter} value type + * @throws IllegalStateException if the LiveObjects plugin is not on the classpath + */ + @NotNull + public static LiveCounter create(@NotNull Number initialCount) { + try { + Class implementation = Class.forName(IMPLEMENTATION_CLASS); + return (LiveCounter) implementation + .getDeclaredConstructor(Number.class) + .newInstance(initialCount); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | + InvocationTargetException e) { + throw new IllegalStateException( + "LiveObjects plugin not found in classpath. LiveObjects functionality will not be available.", e); + } + } +} diff --git a/lib/src/main/java/io/ably/lib/object/value/LiveMap.java b/lib/src/main/java/io/ably/lib/object/value/LiveMap.java new file mode 100644 index 000000000..810149b9c --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/value/LiveMap.java @@ -0,0 +1,75 @@ +package io.ably.lib.object.value; + +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.InvocationTargetException; +import java.util.Collections; +import java.util.Map; + +/** + * An immutable value type representing the intent to create a new + * {@code LiveMap} object with specific initial entries. Passed to mutation + * methods (such as {@code LiveMapInstance#set} or {@code LiveMapPathObject#set}, + * wrapped via {@link LiveMapValue#of(LiveMap)}) to assign a new {@code LiveMap} + * to the objects graph. Entries may themselves contain nested {@code LiveMap} / + * {@code LiveCounter} value types, enabling composable object structures. + * + *

This type is a holder for the initial value only - it is not a live, + * subscribable view of channel state. The {@code MAP_CREATE} operation it gives + * rise to is published when the enclosing mutation is applied. + * + *

Instances are obtained via the static {@link #create(Map)} factory and + * are immutable after creation. The initial entries are held internally by the + * implementation; they have no public accessor. + * + *

Spec: RTLMV1, RTLMV2, RTLMV3 + */ +public abstract class LiveMap { + + private static final String IMPLEMENTATION_CLASS = "io.ably.lib.object.DefaultLiveMap"; + + /** + * Extended by the LiveObjects implementation; not intended for + * application subclassing. Avoids implicit empty public constructor. + */ + protected LiveMap() { + } + + /** + * Creates a new {@code LiveMap} value type with no initial entries. + * + *

Spec: RTLMV3, RTLMV3a1, RTLMV3b + * + * @return an immutable {@code LiveMap} value type + * @throws IllegalStateException if the LiveObjects plugin is not on the classpath + */ + @NotNull + public static LiveMap create() { + return create(Collections.emptyMap()); + } + + /** + * Creates a new {@code LiveMap} value type with the given initial entries. + * No input validation is performed at creation time; validation is deferred + * to when the value is evaluated by a mutation method. + * + *

Spec: RTLMV3, RTLMV3b, RTLMV3c, RTLMV3d + * + * @param entries the initial entries for the new {@code LiveMap} object + * @return an immutable {@code LiveMap} value type + * @throws IllegalStateException if the LiveObjects plugin is not on the classpath + */ + @NotNull + public static LiveMap create(@NotNull Map entries) { + try { + Class implementation = Class.forName(IMPLEMENTATION_CLASS); + return (LiveMap) implementation + .getDeclaredConstructor(Map.class) + .newInstance(entries); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | + InvocationTargetException e) { + throw new IllegalStateException( + "LiveObjects plugin not found in classpath. LiveObjects functionality will not be available.", e); + } + } +} diff --git a/lib/src/main/java/io/ably/lib/object/value/LiveMapValue.java b/lib/src/main/java/io/ably/lib/object/value/LiveMapValue.java new file mode 100644 index 000000000..5eb42b221 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/value/LiveMapValue.java @@ -0,0 +1,439 @@ +package io.ably.lib.object.value; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import org.jetbrains.annotations.NotNull; + +/** + * The union of values assignable to a {@code LiveMap} key: + * {@code Boolean | Binary | Number | String | JsonArray | JsonObject | + * LiveCounter | LiveMap}. Provides compile-time type safety for write + * operations; the design follows Gson's {@code JsonElement} pattern. + * + *

The {@link LiveMap} and {@link LiveCounter} variants hold new-object + * value types describing the initial state of a nested object to create - + * not references to existing live objects. + * + *

Spec: RTPO15a2 / RTINS12a2 / RTLM20 (accepted value types) + */ +public abstract class LiveMapValue { + + /** + * Gets the underlying value. + * + * @return the value as an Object + */ + @NotNull + public abstract Object getValue(); + + /** + * Returns true if this LiveMapValue represents a Boolean value. + * + * @return true if this is a Boolean value + */ + public boolean isBoolean() { return false; } + + /** + * Returns true if this LiveMapValue represents a Binary value. + * + * @return true if this is a Binary value + */ + public boolean isBinary() { return false; } + + /** + * Returns true if this LiveMapValue represents a Number value. + * + * @return true if this is a Number value + */ + public boolean isNumber() { return false; } + + /** + * Returns true if this LiveMapValue represents a String value. + * + * @return true if this is a String value + */ + public boolean isString() { return false; } + + /** + * Returns true if this LiveMapValue represents a JsonArray value. + * + * @return true if this is a JsonArray value + */ + public boolean isJsonArray() { return false; } + + /** + * Returns true if this LiveMapValue represents a JsonObject value. + * + * @return true if this is a JsonObject value + */ + public boolean isJsonObject() { return false; } + + /** + * Returns true if this LiveMapValue represents a new {@link LiveCounter} + * value type. + * + * @return true if this is a LiveCounter value + */ + public boolean isLiveCounter() { return false; } + + /** + * Returns true if this LiveMapValue represents a new {@link LiveMap} + * value type. + * + * @return true if this is a LiveMap value + */ + public boolean isLiveMap() { return false; } + + /** + * Gets the Boolean value if this LiveMapValue represents a Boolean. + * + * @return the Boolean value + * @throws IllegalStateException if this is not a Boolean value + */ + @NotNull + public Boolean getAsBoolean() { + throw new IllegalStateException("Not a Boolean value"); + } + + /** + * Gets the Binary value if this LiveMapValue represents a Binary. + * + * @return the Binary value + * @throws IllegalStateException if this is not a Binary value + */ + public byte @NotNull [] getAsBinary() { + throw new IllegalStateException("Not a Binary value"); + } + + /** + * Gets the Number value if this LiveMapValue represents a Number. + * + * @return the Number value + * @throws IllegalStateException if this is not a Number value + */ + @NotNull + public Number getAsNumber() { + throw new IllegalStateException("Not a Number value"); + } + + /** + * Gets the String value if this LiveMapValue represents a String. + * + * @return the String value + * @throws IllegalStateException if this is not a String value + */ + @NotNull + public String getAsString() { + throw new IllegalStateException("Not a String value"); + } + + /** + * Gets the JsonArray value if this LiveMapValue represents a JsonArray. + * + * @return the JsonArray value + * @throws IllegalStateException if this is not a JsonArray value + */ + @NotNull + public JsonArray getAsJsonArray() { + throw new IllegalStateException("Not a JsonArray value"); + } + + /** + * Gets the JsonObject value if this LiveMapValue represents a JsonObject. + * + * @return the JsonObject value + * @throws IllegalStateException if this is not a JsonObject value + */ + @NotNull + public JsonObject getAsJsonObject() { + throw new IllegalStateException("Not a JsonObject value"); + } + + /** + * Gets the {@link LiveCounter} value type if this LiveMapValue represents one. + * + * @return the LiveCounter value type + * @throws IllegalStateException if this is not a LiveCounter value + */ + @NotNull + public LiveCounter getAsLiveCounter() { + throw new IllegalStateException("Not a LiveCounter value"); + } + + /** + * Gets the {@link LiveMap} value type if this LiveMapValue represents one. + * + * @return the LiveMap value type + * @throws IllegalStateException if this is not a LiveMap value + */ + @NotNull + public LiveMap getAsLiveMap() { + throw new IllegalStateException("Not a LiveMap value"); + } + + /** + * Creates a LiveMapValue from a Boolean. + * + * @param value the boolean value + * @return a LiveMapValue containing the boolean + */ + @NotNull + public static LiveMapValue of(@NotNull Boolean value) { + return new BooleanValue(value); + } + + /** + * Creates a LiveMapValue from a Binary. + * + * @param value the binary value + * @return a LiveMapValue containing the binary + */ + @NotNull + public static LiveMapValue of(byte @NotNull [] value) { + return new BinaryValue(value); + } + + /** + * Creates a LiveMapValue from a Number. + * + * @param value the number value + * @return a LiveMapValue containing the number + */ + @NotNull + public static LiveMapValue of(@NotNull Number value) { + return new NumberValue(value); + } + + /** + * Creates a LiveMapValue from a String. + * + * @param value the string value + * @return a LiveMapValue containing the string + */ + @NotNull + public static LiveMapValue of(@NotNull String value) { + return new StringValue(value); + } + + /** + * Creates a LiveMapValue from a JsonArray. + * + * @param value the JsonArray value + * @return a LiveMapValue containing the JsonArray + */ + @NotNull + public static LiveMapValue of(@NotNull JsonArray value) { + return new JsonArrayValue(value); + } + + /** + * Creates a LiveMapValue from a JsonObject. + * + * @param value the JsonObject value + * @return a LiveMapValue containing the JsonObject + */ + @NotNull + public static LiveMapValue of(@NotNull JsonObject value) { + return new JsonObjectValue(value); + } + + /** + * Creates a LiveMapValue from a new {@link LiveCounter} value type. + * + * @param value the LiveCounter value type + * @return a LiveMapValue containing the LiveCounter + */ + @NotNull + public static LiveMapValue of(@NotNull LiveCounter value) { + return new LiveCounterValue(value); + } + + /** + * Creates a LiveMapValue from a new {@link LiveMap} value type. + * + * @param value the LiveMap value type + * @return a LiveMapValue containing the LiveMap + */ + @NotNull + public static LiveMapValue of(@NotNull LiveMap value) { + return new LiveMapValueWrapper(value); + } + + // Concrete implementations for each allowed type + + /** + * Boolean value implementation. + */ + private static final class BooleanValue extends LiveMapValue { + private final Boolean value; + + BooleanValue(@NotNull Boolean value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isBoolean() { return true; } + + @Override + public @NotNull Boolean getAsBoolean() { return value; } + } + + /** + * Binary value implementation. + */ + private static final class BinaryValue extends LiveMapValue { + private final byte[] value; + + BinaryValue(byte @NotNull [] value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isBinary() { return true; } + + @Override + public byte @NotNull [] getAsBinary() { return value; } + } + + /** + * Number value implementation. + */ + private static final class NumberValue extends LiveMapValue { + private final Number value; + + NumberValue(@NotNull Number value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isNumber() { return true; } + + @Override + public @NotNull Number getAsNumber() { return value; } + } + + /** + * String value implementation. + */ + private static final class StringValue extends LiveMapValue { + private final String value; + + StringValue(@NotNull String value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isString() { return true; } + + @Override + public @NotNull String getAsString() { return value; } + } + + /** + * JsonArray value implementation. + */ + private static final class JsonArrayValue extends LiveMapValue { + private final JsonArray value; + + JsonArrayValue(@NotNull JsonArray value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isJsonArray() { return true; } + + @Override + public @NotNull JsonArray getAsJsonArray() { return value; } + } + + /** + * JsonObject value implementation. + */ + private static final class JsonObjectValue extends LiveMapValue { + private final JsonObject value; + + JsonObjectValue(@NotNull JsonObject value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isJsonObject() { return true; } + + @Override + public @NotNull JsonObject getAsJsonObject() { return value; } + } + + /** + * LiveCounter value implementation. + */ + private static final class LiveCounterValue extends LiveMapValue { + private final LiveCounter value; + + LiveCounterValue(@NotNull LiveCounter value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isLiveCounter() { return true; } + + @Override + public @NotNull LiveCounter getAsLiveCounter() { return value; } + } + + /** + * LiveMap value implementation. + */ + private static final class LiveMapValueWrapper extends LiveMapValue { + private final LiveMap value; + + LiveMapValueWrapper(@NotNull LiveMap value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isLiveMap() { return true; } + + @Override + public @NotNull LiveMap getAsLiveMap() { return value; } + } +} diff --git a/lib/src/main/java/io/ably/lib/object/value/package-info.java b/lib/src/main/java/io/ably/lib/object/value/package-info.java new file mode 100644 index 000000000..583baa039 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/value/package-info.java @@ -0,0 +1,16 @@ +/** + * Write-side value types for LiveObjects mutations. + * {@link io.ably.lib.object.value.LiveMapValue} is the union of values + * assignable to a {@code LiveMap} key; + * {@link io.ably.lib.object.value.LiveMap} and + * {@link io.ably.lib.object.value.LiveCounter} are immutable initial-value + * holders describing new objects to be created by a mutation; they expose only + * the static {@code create} factories (RTLMV3 / RTLCV3), which delegate to the + * LiveObjects implementation extending these abstract classes. Their internal + * state ({@code entries} / {@code count}) is held by the implementation and + * has no public accessor. + * + *

Spec: RTLM20 / RTPO15a2 / RTINS12a2 (value union); RTLMV3 / RTLCV3 + * (new-object value types) + */ +package io.ably.lib.object.value; From 59a5ecccf10d500788e70d4c5c196b915b1ae159 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 10 Jun 2026 17:44:51 +0530 Subject: [PATCH 04/40] Moved subscribe methods to the bottom in `PathObject` interface --- .../io/ably/lib/object/path/PathObject.java | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/lib/src/main/java/io/ably/lib/object/path/PathObject.java b/lib/src/main/java/io/ably/lib/object/path/PathObject.java index 6a96de4ff..0e60bb378 100644 --- a/lib/src/main/java/io/ably/lib/object/path/PathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/PathObject.java @@ -95,36 +95,6 @@ public interface PathObject { */ @Nullable JsonElement compactJson(); - /** - * Subscribes a listener for path-based update events. The listener is invoked when - * an operation modifies the value at this path. The same path may be subscribed by - * multiple listeners independently. Call {@link Subscription#unsubscribe()} - * on the returned handle to stop receiving events for this listener. - * - *

Spec: RTPO19 / RTTS3d - * - * @param listener the listener to invoke on updates - * @return a subscription handle that can be used to unsubscribe this listener - */ - @NonBlocking - @NotNull Subscription subscribe(@NotNull PathObjectListener listener); - - /** - * Subscribes a listener for path-based update events using the provided - * {@link PathObjectSubscriptionOptions}. Options control coverage rules such as the - * {@code depth} of nested updates that trigger the listener. Call - * {@link Subscription#unsubscribe()} on the returned handle to stop - * receiving events for this listener. - * - *

Spec: RTPO19 / RTTS3d - * - * @param listener the listener to invoke on updates - * @param options optional subscription options, may be {@code null} - * @return a subscription handle that can be used to unsubscribe this listener - */ - @NonBlocking - @NotNull Subscription subscribe(@NotNull PathObjectListener listener, @Nullable PathObjectSubscriptionOptions options); - /** * Returns {@code true} if a value currently resolves at this path in the local * object graph. This is a best-effort check evaluated at call time; the answer may @@ -222,4 +192,34 @@ public interface PathObject { * @return a {@link JsonArrayPathObject} view of this path */ @NotNull JsonArrayPathObject asJsonArray(); + + /** + * Subscribes a listener for path-based update events. The listener is invoked when + * an operation modifies the value at this path. The same path may be subscribed by + * multiple listeners independently. Call {@link Subscription#unsubscribe()} + * on the returned handle to stop receiving events for this listener. + * + *

Spec: RTPO19 / RTTS3d + * + * @param listener the listener to invoke on updates + * @return a subscription handle that can be used to unsubscribe this listener + */ + @NonBlocking + @NotNull Subscription subscribe(@NotNull PathObjectListener listener); + + /** + * Subscribes a listener for path-based update events using the provided + * {@link PathObjectSubscriptionOptions}. Options control coverage rules such as the + * {@code depth} of nested updates that trigger the listener. Call + * {@link Subscription#unsubscribe()} on the returned handle to stop + * receiving events for this listener. + * + *

Spec: RTPO19 / RTTS3d + * + * @param listener the listener to invoke on updates + * @param options optional subscription options, may be {@code null} + * @return a subscription handle that can be used to unsubscribe this listener + */ + @NonBlocking + @NotNull Subscription subscribe(@NotNull PathObjectListener listener, @Nullable PathObjectSubscriptionOptions options); } From 11e87a7e4b21a17d4a824fa3f1b62acda2d721ac Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 11 Jun 2026 16:24:15 +0530 Subject: [PATCH 05/40] Addressed PR review comments on liveobjects public API - PathObjectSubscriptionOptions: validate depth fail-fast per RTPO19c1a, throwing AblyException with ErrorInfo(400, 40003) when depth <= 0. Depth is now a primitive int; the "no depth / infinite depth" state is expressed via a new no-arg constructor (mirrors ably-js `{}` options), so no null handling is needed - LiveMapValue: defensively copy binary payloads on creation and access, making the RTLMV3d immutability guarantee real for byte[] values - ObjectData#getBytes: document that the returned array is the underlying message payload and must be treated as read-only - JsonObjectPathObject/JsonArrayPathObject: reword "primitive resolution" javadoc for clarity - LiveMapPathObject#at: fix javadoc equivalence example to compile (get() returns base PathObject, so chain via asLiveMap()) --- .../ably/lib/object/message/ObjectData.java | 3 +- .../path/PathObjectSubscriptionOptions.java | 28 +++++++++++++++++-- .../path/types/JsonArrayPathObject.java | 2 +- .../path/types/JsonObjectPathObject.java | 2 +- .../object/path/types/LiveMapPathObject.java | 2 +- .../ably/lib/object/value/LiveMapValue.java | 9 +++--- 6 files changed, 35 insertions(+), 11 deletions(-) diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectData.java b/lib/src/main/java/io/ably/lib/object/message/ObjectData.java index 72d2b690c..7c2570634 100644 --- a/lib/src/main/java/io/ably/lib/object/message/ObjectData.java +++ b/lib/src/main/java/io/ably/lib/object/message/ObjectData.java @@ -51,7 +51,8 @@ public interface ObjectData { @Nullable Boolean getBoolean(); /** - * Returns the binary value. + * Returns the binary value. The returned array is the underlying message + * payload and is not defensively copied; callers must treat it as read-only. * *

Spec: OD2c * diff --git a/lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionOptions.java b/lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionOptions.java index c586d97d4..cf83c3ae4 100644 --- a/lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionOptions.java +++ b/lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionOptions.java @@ -1,5 +1,7 @@ package io.ably.lib.object.path; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ErrorInfo; import org.jetbrains.annotations.Nullable; /** @@ -13,12 +15,32 @@ public final class PathObjectSubscriptionOptions { private final Integer depth; /** - * Creates options with the given {@code depth}. + * Creates options with no {@code depth} set: there is no depth limit, and + * changes at any depth within nested children trigger the listener. + * Equivalent to passing a {@code null} depth. + * + *

Spec: RTPO19c1 + */ + public PathObjectSubscriptionOptions() { + this.depth = null; + } + + /** + * Creates options with the given {@code depth}. For infinite depth, use the + * no-arg constructor {@link #PathObjectSubscriptionOptions()} instead. + * + *

Spec: RTPO19c1, RTPO19c1a * * @param depth how many levels of path nesting below the subscribed path should - * trigger the listener; must be a positive integer if provided + * trigger the listener; must be a positive integer + * @throws AblyException with {@code statusCode} 400 and {@code code} 40003 if + * {@code depth} is not a positive integer */ - public PathObjectSubscriptionOptions(@Nullable Integer depth) { + public PathObjectSubscriptionOptions(int depth) throws AblyException { + if (depth <= 0) { + throw AblyException.fromErrorInfo( + new ErrorInfo("Subscription depth must be greater than 0 or omitted for infinite depth", 400, 40003)); + } this.depth = depth; } diff --git a/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java index af9bb9ad4..585980bf8 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java @@ -8,7 +8,7 @@ * A {@link PathObject} whose underlying value is expected to be a {@link JsonArray}. * *

This is a terminal type. {@link PathObject#instance()} returns {@code null} - * because a primitive resolution does not produce a wrapped LiveObject; navigation + * because this resolution does not produce a wrapped LiveObject instance; navigation * via {@code at(...)} is not available here because it is only defined on * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are * useful here. diff --git a/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java index c54897070..681fcaa6e 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java @@ -8,7 +8,7 @@ * A {@link PathObject} whose underlying value is expected to be a {@link JsonObject}. * *

This is a terminal type. {@link PathObject#instance()} returns {@code null} - * because a primitive resolution does not produce a wrapped LiveObject; navigation + * because this resolution does not produce a wrapped LiveObject instance; navigation * via {@code at(...)} is not available here because it is only defined on * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are * useful here. diff --git a/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java b/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java index 11cbe4c4f..6c4f0ab00 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java @@ -46,7 +46,7 @@ public interface LiveMapPathObject extends PathObject { * *

This is purely navigational - no resolution against the LiveObjects graph is * performed by this call. {@code liveMapPath.at("a.b.c")} is equivalent to - * {@code liveMapPath.get("a").get("b").get("c")}. + * {@code liveMapPath.get("a").asLiveMap().get("b").asLiveMap().get("c")}. * *

Available only on {@code LiveMapPathObject} because deeper navigation is only * meaningful when the current resolved value is a {@code LiveMap}. To traverse from diff --git a/lib/src/main/java/io/ably/lib/object/value/LiveMapValue.java b/lib/src/main/java/io/ably/lib/object/value/LiveMapValue.java index 5eb42b221..5f80595a5 100644 --- a/lib/src/main/java/io/ably/lib/object/value/LiveMapValue.java +++ b/lib/src/main/java/io/ably/lib/object/value/LiveMapValue.java @@ -183,7 +183,8 @@ public static LiveMapValue of(@NotNull Boolean value) { } /** - * Creates a LiveMapValue from a Binary. + * Creates a LiveMapValue from a Binary. The array is copied, so later + * modifications to {@code value} do not affect the created LiveMapValue. * * @param value the binary value * @return a LiveMapValue containing the binary @@ -290,19 +291,19 @@ private static final class BinaryValue extends LiveMapValue { private final byte[] value; BinaryValue(byte @NotNull [] value) { - this.value = value; + this.value = value.clone(); } @Override public @NotNull Object getValue() { - return value; + return value.clone(); } @Override public boolean isBinary() { return true; } @Override - public byte @NotNull [] getAsBinary() { return value; } + public byte @NotNull [] getAsBinary() { return value.clone(); } } /** From bfb6de12dfffc8fb047200193bb2625f498fe2ee Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 15 Jun 2026 19:34:37 +0530 Subject: [PATCH 06/40] feat(liveobjects): add path-based RealtimeObject and channel.object accessor Introduce the public, strongly-typed, path-based LiveObjects entry point on a realtime channel, accessed via `channel.object`. - RealtimeObject: exposes `get()` returning the root LiveMapPathObject, and extends ObjectStateChange to subscribe to objects sync-state events (on/off/offAll). - ObjectStateChange / ObjectStateEvent: the SYNCING/SYNCED sync-state subscription API surface. - ChannelBase.object: a public field providing `channel.object` access. When the LiveObjects plugin is not installed, the field is assigned RealtimeObject.Unavailable - a null-object guard whose methods fail fast with a clear plugin-missing error (statusCode 400, code 40019) instead of an NPE, keeping the `channel.object.()` syntax consistent in both cases. The plugin-present branch is intentionally left as a TODO until the LiveObjects plugin exposes the new io.ably.lib.object.RealtimeObject type (getInstance currently returns the legacy io.ably.lib.objects.RealtimeObjects). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/ably/lib/object/RealtimeObject.java | 92 +++++++++++++++++++ .../lib/object/state/ObjectStateChange.java | 56 +++++++++++ .../lib/object/state/ObjectStateEvent.java | 19 ++++ .../io/ably/lib/realtime/ChannelBase.java | 10 +- 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 lib/src/main/java/io/ably/lib/object/RealtimeObject.java create mode 100644 lib/src/main/java/io/ably/lib/object/state/ObjectStateChange.java create mode 100644 lib/src/main/java/io/ably/lib/object/state/ObjectStateEvent.java diff --git a/lib/src/main/java/io/ably/lib/object/RealtimeObject.java b/lib/src/main/java/io/ably/lib/object/RealtimeObject.java new file mode 100644 index 000000000..0face9651 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/RealtimeObject.java @@ -0,0 +1,92 @@ +package io.ably.lib.object; + +import io.ably.lib.object.path.types.LiveMapPathObject; +import io.ably.lib.object.state.ObjectStateChange; +import io.ably.lib.object.state.ObjectStateEvent; +import io.ably.lib.objects.ObjectsSubscription; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ErrorInfo; +import org.jetbrains.annotations.Blocking; +import org.jetbrains.annotations.NotNull; + +/** + * The RealtimeObject interface is the entry point to the strongly-typed, path-based + * LiveObjects API on a channel. It exposes the root of the objects graph as a + * {@link LiveMapPathObject} and, via {@link ObjectStateChange}, lets callers observe + * synchronization state transitions for the channel's objects. + * + *

Implementations of this interface must be thread-safe as they may be accessed + * from multiple threads concurrently. + * + *

Spec: RTO23 + */ +public interface RealtimeObject extends ObjectStateChange { + + /** + * Retrieves a {@link LiveMapPathObject} rooted at the channel's root {@code LiveMap}. + * The returned object has an empty path and resolves to the root {@code LiveMap}; use + * its navigation methods to address nested values within the objects graph. + * + *

When called without a type variable, we return a default root type which is based + * on the globally defined interface for the Objects feature. A user can provide an + * explicit type to set the type structure on this particular channel. This is useful + * when working with multiple channels with different underlying data structures. + * + *

This operation requires the {@code OBJECT_SUBSCRIBE} channel mode. It implicitly + * attaches the channel if it is not already attached, and waits for the objects + * synchronization state to transition to {@code SYNCED} before returning. + * + *

Spec: RTO23, RTO23f (typed SDKs return a {@link LiveMapPathObject}) + * + * @return the root {@link LiveMapPathObject} for this channel's objects graph. + */ + @Blocking + @NotNull + LiveMapPathObject get(); + + /** + * Null-Object guard for {@link RealtimeObject}, used as the value of {@code channel.object} + * when the LiveObjects plugin is not installed. + * + *

Because {@code channel.object} is a field, dereferencing it can never throw; instead + * every method here fails fast with the plugin-missing error, so {@code get()}, {@code on()}, + * {@code off()} and {@code offAll()} surface a clear, consistent error rather than a + * {@link NullPointerException}. + * + *

A stateless singleton ({@link #INSTANCE}) shared across all channels that lack the + * plugin. Adding a method to {@link RealtimeObject} will fail compilation here until it is + * guarded, which is the intended safety net. + */ + final class Unavailable implements RealtimeObject { + + public static final Unavailable INSTANCE = new Unavailable(); + + private Unavailable() {} + + @Override + public @NotNull LiveMapPathObject get() { + throw missing(); + } + + @Override + public ObjectsSubscription on(@NotNull ObjectStateEvent event, ObjectStateChange.@NotNull Listener listener) { + throw missing(); + } + + @Override + public void off(ObjectStateChange.@NotNull Listener listener) { + throw missing(); + } + + @Override + public void offAll() { + throw missing(); + } + + private static RuntimeException missing() { + return new IllegalStateException("LiveObjects plugin hasn't been installed", AblyException.fromErrorInfo( + new ErrorInfo("add runtimeOnly('io.ably:liveobjects:') to your dependency tree", 400, 40019) + )); + } + } +} diff --git a/lib/src/main/java/io/ably/lib/object/state/ObjectStateChange.java b/lib/src/main/java/io/ably/lib/object/state/ObjectStateChange.java new file mode 100644 index 000000000..80f101804 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/state/ObjectStateChange.java @@ -0,0 +1,56 @@ +package io.ably.lib.object.state; + +import io.ably.lib.objects.ObjectsSubscription; +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.NotNull; + +public interface ObjectStateChange { + /** + * Subscribes to a specific Objects synchronization state event. + * + *

This method registers the provided listener to be notified when the specified + * synchronization state event occurs. The returned subscription can be used to + * unsubscribe later when the notifications are no longer needed. + * + * @param event the synchronization state event to subscribe to (SYNCING or SYNCED) + * @param listener the listener that will be called when the event occurs + * @return a subscription object that can be used to unsubscribe from the event + */ + @NonBlocking + ObjectsSubscription on(@NotNull ObjectStateEvent event, @NotNull ObjectStateChange.Listener listener); + + /** + * Unsubscribes the specified listener from all synchronization state events. + * + *

After calling this method, the provided listener will no longer receive + * any synchronization state event notifications. + * + * @param listener the listener to unregister from all events + */ + @NonBlocking + void off(@NotNull ObjectStateChange.Listener listener); + + /** + * Unsubscribes all listeners from all synchronization state events. + * + *

After calling this method, no listeners will receive any synchronization + * state event notifications until new listeners are registered. + */ + @NonBlocking + void offAll(); + + /** + * Interface for receiving notifications about Objects synchronization state changes. + *

+ * Implement this interface and register it with an {@code ObjectStateEmitter} to be notified + * when synchronization state transitions occur. + */ + interface Listener { + /** + * Called when the synchronization state changes. + * + * @param objectStateEvent The new state event (SYNCING or SYNCED) + */ + void onStateChanged(ObjectStateEvent objectStateEvent); + } +} diff --git a/lib/src/main/java/io/ably/lib/object/state/ObjectStateEvent.java b/lib/src/main/java/io/ably/lib/object/state/ObjectStateEvent.java new file mode 100644 index 000000000..9c9a45045 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/state/ObjectStateEvent.java @@ -0,0 +1,19 @@ +package io.ably.lib.object.state; + +/** + * Represents the synchronization state of Ably Objects. + *

+ * This enum is used to notify listeners about state changes in the synchronization process. + * Clients can register an {@link ObjectStateChange.Listener} to receive these events. + */ +public enum ObjectStateEvent { + /** + * Indicates that synchronization between local and remote objects is in progress. + */ + SYNCING, + + /** + * Indicates that synchronization has completed successfully and objects are in sync. + */ + SYNCED +} diff --git a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java index a778e3391..d521f0b4a 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java +++ b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java @@ -13,6 +13,7 @@ import io.ably.lib.http.Http; import io.ably.lib.http.HttpCore; import io.ably.lib.http.HttpUtils; +import io.ably.lib.object.RealtimeObject; import io.ably.lib.objects.RealtimeObjects; import io.ably.lib.objects.LiveObjectsPlugin; import io.ably.lib.rest.MessageEditsMixin; @@ -112,6 +113,8 @@ public abstract class ChannelBase extends EventEmitter Date: Tue, 16 Jun 2026 16:42:41 +0530 Subject: [PATCH 07/40] - Marked `channel.object.get` method non-blocking using completablefuture - Replaced ObjectsSubscription import with Subscription as per requirement --- .../io/ably/lib/object/RealtimeObject.java | 19 ++++++++++--------- .../lib/object/state/ObjectStateChange.java | 4 ++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/lib/src/main/java/io/ably/lib/object/RealtimeObject.java b/lib/src/main/java/io/ably/lib/object/RealtimeObject.java index 0face9651..013a4a649 100644 --- a/lib/src/main/java/io/ably/lib/object/RealtimeObject.java +++ b/lib/src/main/java/io/ably/lib/object/RealtimeObject.java @@ -3,12 +3,12 @@ import io.ably.lib.object.path.types.LiveMapPathObject; import io.ably.lib.object.state.ObjectStateChange; import io.ably.lib.object.state.ObjectStateEvent; -import io.ably.lib.objects.ObjectsSubscription; import io.ably.lib.types.AblyException; import io.ably.lib.types.ErrorInfo; -import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NotNull; +import java.util.concurrent.CompletableFuture; + /** * The RealtimeObject interface is the entry point to the strongly-typed, path-based * LiveObjects API on a channel. It exposes the root of the objects graph as a @@ -33,16 +33,17 @@ public interface RealtimeObject extends ObjectStateChange { * when working with multiple channels with different underlying data structures. * *

This operation requires the {@code OBJECT_SUBSCRIBE} channel mode. It implicitly - * attaches the channel if it is not already attached, and waits for the objects - * synchronization state to transition to {@code SYNCED} before returning. + * attaches the channel if it is not already attached; the returned future completes once + * the objects synchronization state has transitioned to {@code SYNCED}, and completes + * exceptionally with an {@code AblyException} if synchronization fails. * *

Spec: RTO23, RTO23f (typed SDKs return a {@link LiveMapPathObject}) * - * @return the root {@link LiveMapPathObject} for this channel's objects graph. + * @return a future that completes with the root {@link LiveMapPathObject} for this + * channel's objects graph. */ - @Blocking @NotNull - LiveMapPathObject get(); + CompletableFuture get(); /** * Null-Object guard for {@link RealtimeObject}, used as the value of {@code channel.object} @@ -64,12 +65,12 @@ final class Unavailable implements RealtimeObject { private Unavailable() {} @Override - public @NotNull LiveMapPathObject get() { + public @NotNull CompletableFuture get() { throw missing(); } @Override - public ObjectsSubscription on(@NotNull ObjectStateEvent event, ObjectStateChange.@NotNull Listener listener) { + public Subscription on(@NotNull ObjectStateEvent event, ObjectStateChange.@NotNull Listener listener) { throw missing(); } diff --git a/lib/src/main/java/io/ably/lib/object/state/ObjectStateChange.java b/lib/src/main/java/io/ably/lib/object/state/ObjectStateChange.java index 80f101804..bf457fddf 100644 --- a/lib/src/main/java/io/ably/lib/object/state/ObjectStateChange.java +++ b/lib/src/main/java/io/ably/lib/object/state/ObjectStateChange.java @@ -1,6 +1,6 @@ package io.ably.lib.object.state; -import io.ably.lib.objects.ObjectsSubscription; +import io.ably.lib.object.Subscription; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; @@ -17,7 +17,7 @@ public interface ObjectStateChange { * @return a subscription object that can be used to unsubscribe from the event */ @NonBlocking - ObjectsSubscription on(@NotNull ObjectStateEvent event, @NotNull ObjectStateChange.Listener listener); + Subscription on(@NotNull ObjectStateEvent event, @NotNull ObjectStateChange.Listener listener); /** * Unsubscribes the specified listener from all synchronization state events. From 6227b756922e4b11dcf6486c9f371746d94726fb Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Tue, 16 Jun 2026 23:11:26 +0530 Subject: [PATCH 08/40] Added basic impl. for PathObject and Instance liveobjects interfaces --- .../lib/object/adapter/AblyClientAdapter.java | 53 +++++++++++++ .../io/ably/lib/object/adapter/Adapter.java | 50 +++++++++++++ .../ably/lib/object/adapter/package-info.java | 10 +++ .../ably/lib/object/DefaultRealtimeObject.kt | 35 +++++++++ .../main/kotlin/io/ably/lib/object/Helpers.kt | 20 +++++ .../lib/object/instance/DefaultInstance.kt | 46 ++++++++++++ .../DefaultInstanceSubscriptionEvent.kt | 20 +++++ .../instance/types/DefaultBinaryInstance.kt | 25 +++++++ .../instance/types/DefaultBooleanInstance.kt | 25 +++++++ .../types/DefaultJsonArrayInstance.kt | 26 +++++++ .../types/DefaultJsonObjectInstance.kt | 26 +++++++ .../types/DefaultLiveCounterInstance.kt | 46 ++++++++++++ .../instance/types/DefaultLiveMapInstance.kt | 53 +++++++++++++ .../instance/types/DefaultNumberInstance.kt | 25 +++++++ .../instance/types/DefaultStringInstance.kt | 25 +++++++ .../ably/lib/object/path/DefaultPathObject.kt | 74 +++++++++++++++++++ .../DefaultPathObjectSubscriptionEvent.kt | 20 +++++ .../path/types/DefaultBinaryPathObject.kt | 18 +++++ .../path/types/DefaultBooleanPathObject.kt | 18 +++++ .../path/types/DefaultJsonArrayPathObject.kt | 19 +++++ .../path/types/DefaultJsonObjectPathObject.kt | 19 +++++ .../types/DefaultLiveCounterPathObject.kt | 29 ++++++++ .../path/types/DefaultLiveMapPathObject.kt | 35 +++++++++ .../path/types/DefaultNumberPathObject.kt | 18 +++++ .../path/types/DefaultStringPathObject.kt | 18 +++++ 25 files changed, 753 insertions(+) create mode 100644 lib/src/main/java/io/ably/lib/object/adapter/AblyClientAdapter.java create mode 100644 lib/src/main/java/io/ably/lib/object/adapter/Adapter.java create mode 100644 lib/src/main/java/io/ably/lib/object/adapter/package-info.java create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObject.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstance.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstanceSubscriptionEvent.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBinaryInstance.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBooleanInstance.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonArrayInstance.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonObjectInstance.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveCounterInstance.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveMapInstance.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultNumberInstance.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultStringInstance.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObjectSubscriptionEvent.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt diff --git a/lib/src/main/java/io/ably/lib/object/adapter/AblyClientAdapter.java b/lib/src/main/java/io/ably/lib/object/adapter/AblyClientAdapter.java new file mode 100644 index 000000000..3204708b3 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/adapter/AblyClientAdapter.java @@ -0,0 +1,53 @@ +package io.ably.lib.object.adapter; + +import io.ably.lib.realtime.ChannelBase; +import io.ably.lib.realtime.Connection; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import org.jetbrains.annotations.Blocking; +import org.jetbrains.annotations.NotNull; + +/** + * Bridges the path-based LiveObjects implementation to the core Ably client, exposing the + * client configuration, connection and channel state it needs without coupling it to the + * concrete {@link io.ably.lib.realtime.AblyRealtime} type. + * + *

This is the adapter for the path-based {@code io.ably.lib.object} API and is intentionally + * kept independent of the legacy {@code io.ably.lib.objects} package. + */ +public interface AblyClientAdapter { + /** + * Retrieves the client options configured for the Ably client. + * Used to access client configuration parameters such as echoMessages setting + * that affect the behavior of Objects operations. + * + * @return the client options containing configuration parameters + */ + @NotNull ClientOptions getClientOptions(); + + /** + * Retrieves the connection instance for handling connection state and operations. + * Used to check connection status, obtain error information, and manage + * message transmission across the Ably connection. + * + * @return the connection instance + */ + @NotNull Connection getConnection(); + + /** + * Retrieves the current time in milliseconds from the Ably server. + * Spec: RTO16 + */ + @Blocking + long getTime() throws AblyException; + + /** + * Retrieves the channel instance for the specified channel name. + * If the channel does not exist, an AblyException is thrown. + * + * @param channelName the name of the channel to retrieve + * @return the ChannelBase instance for the specified channel + * @throws AblyException if the channel is not found or cannot be retrieved + */ + @NotNull ChannelBase getChannel(@NotNull String channelName) throws AblyException; +} diff --git a/lib/src/main/java/io/ably/lib/object/adapter/Adapter.java b/lib/src/main/java/io/ably/lib/object/adapter/Adapter.java new file mode 100644 index 000000000..d67485e37 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/adapter/Adapter.java @@ -0,0 +1,50 @@ +package io.ably.lib.object.adapter; + +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.ChannelBase; +import io.ably.lib.realtime.Connection; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.util.Log; +import org.jetbrains.annotations.NotNull; + +/** + * Default {@link AblyClientAdapter} implementation backed by an {@link AblyRealtime} client. + * Holding the {@code AblyRealtime} reference gives the path-based LiveObjects implementation + * access to the full client configuration and runtime state it may need. + */ +public class Adapter implements AblyClientAdapter { + private final AblyRealtime ably; + private static final String TAG = AblyClientAdapter.class.getName(); + + public Adapter(@NotNull AblyRealtime ably) { + this.ably = ably; + } + + @Override + public @NotNull ClientOptions getClientOptions() { + return ably.options; + } + + @Override + public @NotNull Connection getConnection() { + return ably.connection; + } + + @Override + public long getTime() throws AblyException { + return ably.time(); + } + + @Override + public @NotNull ChannelBase getChannel(@NotNull String channelName) throws AblyException { + if (ably.channels.containsKey(channelName)) { + return ably.channels.get(channelName); + } else { + Log.e(TAG, "getChannel(): channel not found: " + channelName); + ErrorInfo errorInfo = new ErrorInfo("Channel not found: " + channelName, 404); + throw AblyException.fromErrorInfo(errorInfo); + } + } +} diff --git a/lib/src/main/java/io/ably/lib/object/adapter/package-info.java b/lib/src/main/java/io/ably/lib/object/adapter/package-info.java new file mode 100644 index 000000000..c1589741b --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/adapter/package-info.java @@ -0,0 +1,10 @@ +/** + * Adapter layer bridging the path-based LiveObjects implementation to the core Ably client. + * {@link io.ably.lib.object.adapter.AblyClientAdapter} is the abstraction the implementation + * depends on; {@link io.ably.lib.object.adapter.Adapter} is the default implementation backed + * by an {@link io.ably.lib.realtime.AblyRealtime} client. + * + *

This package is intentionally independent of the legacy {@code io.ably.lib.objects} + * package so the path-based API can evolve on its own. + */ +package io.ably.lib.object.adapter; diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObject.kt new file mode 100644 index 000000000..11807cbaa --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObject.kt @@ -0,0 +1,35 @@ +package io.ably.lib.`object` + +import io.ably.lib.`object`.adapter.AblyClientAdapter +import io.ably.lib.`object`.path.types.LiveMapPathObject +import io.ably.lib.`object`.state.ObjectStateChange +import io.ably.lib.`object`.state.ObjectStateEvent +import java.util.concurrent.CompletableFuture + +/** + * Default implementation of [RealtimeObject], the entry point to the strongly-typed, + * path-based LiveObjects API for a single channel. + * + * This is currently a skeleton: the path-based read and subscribe operations are not yet + * implemented. The method bodies will be filled in as the path-based API is built out. + * + * Spec: RTO23 + */ +internal class DefaultRealtimeObject( + internal val channelName: String, + internal val adapter: AblyClientAdapter, +) : RealtimeObject { + + override fun get(): CompletableFuture = TODO("Not yet implemented") + + override fun on(event: ObjectStateEvent, listener: ObjectStateChange.Listener): Subscription { + // TODO - subscribe logic goes here + return onceSubscription { + // TODO - remove ObjectStateChange.Listener + } + } + + override fun off(listener: ObjectStateChange.Listener): Unit = TODO("Not yet implemented") + + override fun offAll(): Unit = TODO("Not yet implemented") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt new file mode 100644 index 000000000..3bc7df1fd --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt @@ -0,0 +1,20 @@ +package io.ably.lib.`object` + +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Wraps [onUnsubscribe] in a [Subscription] that runs the cleanup at most once; further + * calls are no-ops. Use it wherever a [Subscription] is returned: `EventEmitter.off` is + * `synchronized`, so this avoids re-acquiring that lock (and re-running teardown) on + * repeated unsubscribe calls. Thread-safe. + * + * Spec: SUB2a, SUB2b + */ +internal fun onceSubscription(onUnsubscribe: () -> Unit): Subscription { + val unsubscribed = AtomicBoolean(false) + return Subscription { + if (unsubscribed.compareAndSet(false, true)) { + onUnsubscribe() + } + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstance.kt new file mode 100644 index 000000000..151949b1b --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstance.kt @@ -0,0 +1,46 @@ +package io.ably.lib.`object`.instance + +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.instance.types.BinaryInstance +import io.ably.lib.`object`.instance.types.BooleanInstance +import io.ably.lib.`object`.instance.types.JsonArrayInstance +import io.ably.lib.`object`.instance.types.JsonObjectInstance +import io.ably.lib.`object`.instance.types.LiveCounterInstance +import io.ably.lib.`object`.instance.types.LiveMapInstance +import io.ably.lib.`object`.instance.types.NumberInstance +import io.ably.lib.`object`.instance.types.StringInstance + +/** + * Default implementation of [Instance], the identity-addressed node in the LiveObjects graph. + * + * An instance is always bound to a specific resolved value of a known type, so this base is + * abstract: each concrete sub-type supplies [getType] and [compactJson] (left abstract here) + * and overrides only the single `as*` cast matching its own type to return `this`. The + * remaining `as*` casts fall through to the implementations here, which fail fast because the + * wrapped value is not of the requested type. + * + * Only the channel's [channelObject] context is carried; unlike a path object there is no + * parent/child path, since an instance is identity-addressed. + * + * Spec: RTINS1, RTTS7 + */ +internal abstract class DefaultInstance( + internal val channelObject: DefaultRealtimeObject, +) : Instance { + + override fun asLiveMap(): LiveMapInstance = throw IllegalStateException("Not a LiveMap instance") + + override fun asLiveCounter(): LiveCounterInstance = throw IllegalStateException("Not a LiveCounter instance") + + override fun asNumber(): NumberInstance = throw IllegalStateException("Not a Number instance") + + override fun asString(): StringInstance = throw IllegalStateException("Not a String instance") + + override fun asBoolean(): BooleanInstance = throw IllegalStateException("Not a Boolean instance") + + override fun asBinary(): BinaryInstance = throw IllegalStateException("Not a Binary instance") + + override fun asJsonObject(): JsonObjectInstance = throw IllegalStateException("Not a JsonObject instance") + + override fun asJsonArray(): JsonArrayInstance = throw IllegalStateException("Not a JsonArray instance") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstanceSubscriptionEvent.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstanceSubscriptionEvent.kt new file mode 100644 index 000000000..aaa47108c --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstanceSubscriptionEvent.kt @@ -0,0 +1,20 @@ +package io.ably.lib.`object`.instance + +import io.ably.lib.`object`.message.ObjectMessage + +/** + * Default implementation of [InstanceSubscriptionEvent], the event delivered to an + * [InstanceListener] when the wrapped LiveObject is updated. A plain holder for the updated + * [Instance] and the source [ObjectMessage] (if any). + * + * Spec: RTINS16e + */ +internal class DefaultInstanceSubscriptionEvent( + private val objectAt: Instance, + private val message: ObjectMessage?, +) : InstanceSubscriptionEvent { + + override fun getObject(): Instance = objectAt + + override fun getMessage(): ObjectMessage? = message +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBinaryInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBinaryInstance.kt new file mode 100644 index 000000000..6ef67dfaa --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBinaryInstance.kt @@ -0,0 +1,25 @@ +package io.ably.lib.`object`.instance.types + +import com.google.gson.JsonElement +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.ValueType +import io.ably.lib.`object`.instance.DefaultInstance + +/** + * Default implementation of [BinaryInstance], a read-only primitive view that only adds a + * type-narrowed, non-null [value]; left unimplemented for now. + * + * Spec: RTTS10c + */ +internal class DefaultBinaryInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), BinaryInstance { + + override fun getType(): ValueType = ValueType.BINARY + + override fun compactJson(): JsonElement = TODO("Not yet implemented") + + override fun asBinary(): BinaryInstance = this + + override fun value(): ByteArray = TODO("Not yet implemented") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBooleanInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBooleanInstance.kt new file mode 100644 index 000000000..9971be07a --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBooleanInstance.kt @@ -0,0 +1,25 @@ +package io.ably.lib.`object`.instance.types + +import com.google.gson.JsonElement +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.ValueType +import io.ably.lib.`object`.instance.DefaultInstance + +/** + * Default implementation of [BooleanInstance], a read-only primitive view that only adds a + * type-narrowed, non-null [value]; left unimplemented for now. + * + * Spec: RTTS10c + */ +internal class DefaultBooleanInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), BooleanInstance { + + override fun getType(): ValueType = ValueType.BOOLEAN + + override fun compactJson(): JsonElement = TODO("Not yet implemented") + + override fun asBoolean(): BooleanInstance = this + + override fun value(): Boolean = TODO("Not yet implemented") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonArrayInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonArrayInstance.kt new file mode 100644 index 000000000..2cebbdfed --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonArrayInstance.kt @@ -0,0 +1,26 @@ +package io.ably.lib.`object`.instance.types + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.ValueType +import io.ably.lib.`object`.instance.DefaultInstance + +/** + * Default implementation of [JsonArrayInstance], a read-only primitive view that only adds + * a type-narrowed, non-null [value]; left unimplemented for now. + * + * Spec: RTTS10c + */ +internal class DefaultJsonArrayInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), JsonArrayInstance { + + override fun getType(): ValueType = ValueType.JSON_ARRAY + + override fun compactJson(): JsonElement = TODO("Not yet implemented") + + override fun asJsonArray(): JsonArrayInstance = this + + override fun value(): JsonArray = TODO("Not yet implemented") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonObjectInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonObjectInstance.kt new file mode 100644 index 000000000..36c00fff1 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonObjectInstance.kt @@ -0,0 +1,26 @@ +package io.ably.lib.`object`.instance.types + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.ValueType +import io.ably.lib.`object`.instance.DefaultInstance + +/** + * Default implementation of [JsonObjectInstance], a read-only primitive view that only adds + * a type-narrowed, non-null [value]; left unimplemented for now. + * + * Spec: RTTS10c + */ +internal class DefaultJsonObjectInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), JsonObjectInstance { + + override fun getType(): ValueType = ValueType.JSON_OBJECT + + override fun compactJson(): JsonElement = TODO("Not yet implemented") + + override fun asJsonObject(): JsonObjectInstance = this + + override fun value(): JsonObject = TODO("Not yet implemented") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveCounterInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveCounterInstance.kt new file mode 100644 index 000000000..b90e1330f --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveCounterInstance.kt @@ -0,0 +1,46 @@ +package io.ably.lib.`object`.instance.types + +import com.google.gson.JsonElement +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.Subscription +import io.ably.lib.`object`.ValueType +import io.ably.lib.`object`.instance.DefaultInstance +import io.ably.lib.`object`.instance.InstanceListener +import io.ably.lib.`object`.onceSubscription +import java.util.concurrent.CompletableFuture + +/** + * Default implementation of [LiveCounterInstance], adding counter operations and subscribe + * on top of [DefaultInstance]; all left unimplemented for now. + * + * Spec: RTTS10b + */ +internal class DefaultLiveCounterInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), LiveCounterInstance { + + override fun getType(): ValueType = ValueType.LIVE_COUNTER + + override fun compactJson(): JsonElement = TODO("Not yet implemented") + + override fun asLiveCounter(): LiveCounterInstance = this + + override fun getId(): String = TODO("Not yet implemented") + + override fun value(): Double = TODO("Not yet implemented") + + override fun increment(): CompletableFuture = TODO("Not yet implemented") + + override fun increment(amount: Number): CompletableFuture = TODO("Not yet implemented") + + override fun decrement(): CompletableFuture = TODO("Not yet implemented") + + override fun decrement(amount: Number): CompletableFuture = TODO("Not yet implemented") + + override fun subscribe(listener: InstanceListener): Subscription { + // TODO - subscribe logic goes here + return onceSubscription { + // TODO - remove InstanceListener + } + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveMapInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveMapInstance.kt new file mode 100644 index 000000000..816cc202f --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveMapInstance.kt @@ -0,0 +1,53 @@ +package io.ably.lib.`object`.instance.types + +import com.google.gson.JsonElement +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.Subscription +import io.ably.lib.`object`.ValueType +import io.ably.lib.`object`.instance.DefaultInstance +import io.ably.lib.`object`.instance.Instance +import io.ably.lib.`object`.instance.InstanceListener +import io.ably.lib.`object`.onceSubscription +import io.ably.lib.`object`.value.LiveMapValue +import java.util.concurrent.CompletableFuture + +/** + * Default implementation of [LiveMapInstance], adding map reads, writes and subscribe on top + * of [DefaultInstance]; all left unimplemented for now. + * + * Spec: RTTS10a + */ +internal class DefaultLiveMapInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), LiveMapInstance { + + override fun getType(): ValueType = ValueType.LIVE_MAP + + override fun compactJson(): JsonElement = TODO("Not yet implemented") + + override fun asLiveMap(): LiveMapInstance = this + + override fun getId(): String = TODO("Not yet implemented") + + @Suppress("RedundantNullableReturnType") + override fun get(key: String): Instance? = TODO("Not yet implemented") + + override fun entries(): Iterable> = TODO("Not yet implemented") + + override fun keys(): Iterable = TODO("Not yet implemented") + + override fun values(): Iterable = TODO("Not yet implemented") + + override fun size(): Long = TODO("Not yet implemented") + + override fun set(key: String, value: LiveMapValue): CompletableFuture = TODO("Not yet implemented") + + override fun remove(key: String): CompletableFuture = TODO("Not yet implemented") + + override fun subscribe(listener: InstanceListener): Subscription { + // TODO - subscribe logic goes here + return onceSubscription { + // TODO - remove InstanceListener + } + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultNumberInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultNumberInstance.kt new file mode 100644 index 000000000..230e7250a --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultNumberInstance.kt @@ -0,0 +1,25 @@ +package io.ably.lib.`object`.instance.types + +import com.google.gson.JsonElement +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.ValueType +import io.ably.lib.`object`.instance.DefaultInstance + +/** + * Default implementation of [NumberInstance], a read-only primitive view that only adds a + * type-narrowed, non-null [value]; left unimplemented for now. + * + * Spec: RTTS10c + */ +internal class DefaultNumberInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), NumberInstance { + + override fun getType(): ValueType = ValueType.NUMBER + + override fun compactJson(): JsonElement = TODO("Not yet implemented") + + override fun asNumber(): NumberInstance = this + + override fun value(): Number = TODO("Not yet implemented") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultStringInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultStringInstance.kt new file mode 100644 index 000000000..c1a392269 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultStringInstance.kt @@ -0,0 +1,25 @@ +package io.ably.lib.`object`.instance.types + +import com.google.gson.JsonElement +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.ValueType +import io.ably.lib.`object`.instance.DefaultInstance + +/** + * Default implementation of [StringInstance], a read-only primitive view that only adds a + * type-narrowed, non-null [value]; left unimplemented for now. + * + * Spec: RTTS10c + */ +internal class DefaultStringInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), StringInstance { + + override fun getType(): ValueType = ValueType.STRING + + override fun compactJson(): JsonElement = TODO("Not yet implemented") + + override fun asString(): StringInstance = this + + override fun value(): String = TODO("Not yet implemented") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt new file mode 100644 index 000000000..3535ef83f --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt @@ -0,0 +1,74 @@ +package io.ably.lib.`object`.path + +import com.google.gson.JsonElement +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.Subscription +import io.ably.lib.`object`.ValueType +import io.ably.lib.`object`.instance.Instance +import io.ably.lib.`object`.onceSubscription +import io.ably.lib.`object`.path.types.BinaryPathObject +import io.ably.lib.`object`.path.types.BooleanPathObject +import io.ably.lib.`object`.path.types.DefaultBinaryPathObject +import io.ably.lib.`object`.path.types.DefaultBooleanPathObject +import io.ably.lib.`object`.path.types.DefaultJsonArrayPathObject +import io.ably.lib.`object`.path.types.DefaultJsonObjectPathObject +import io.ably.lib.`object`.path.types.DefaultLiveCounterPathObject +import io.ably.lib.`object`.path.types.DefaultLiveMapPathObject +import io.ably.lib.`object`.path.types.DefaultNumberPathObject +import io.ably.lib.`object`.path.types.DefaultStringPathObject +import io.ably.lib.`object`.path.types.JsonArrayPathObject +import io.ably.lib.`object`.path.types.JsonObjectPathObject +import io.ably.lib.`object`.path.types.LiveCounterPathObject +import io.ably.lib.`object`.path.types.LiveMapPathObject +import io.ably.lib.`object`.path.types.NumberPathObject +import io.ably.lib.`object`.path.types.StringPathObject + +/** + * Default implementation of [PathObject], the untyped node in the path-addressed view of + * the LiveObjects graph. + * + * This is a skeleton. The `as*` casts return a typed view of the same position; the + * operations that require resolving the path against the live objects graph are left + * unimplemented for now and will be filled in as the path-based API is built out. + * + * Spec: RTPO1, RTPO2, RTTS3 + */ +internal open class DefaultPathObject( + internal val channelObject: DefaultRealtimeObject, +) : PathObject { + + override fun path(): String = TODO("Not yet implemented") + + override fun getType(): ValueType = TODO("Not yet implemented") + + override fun instance(): Instance? = TODO("Not yet implemented") + + override fun compactJson(): JsonElement? = TODO("Not yet implemented") + + override fun exists(): Boolean = TODO("Not yet implemented") + + override fun asLiveMap(): LiveMapPathObject = DefaultLiveMapPathObject(channelObject) + + override fun asLiveCounter(): LiveCounterPathObject = DefaultLiveCounterPathObject(channelObject) + + override fun asNumber(): NumberPathObject = DefaultNumberPathObject(channelObject) + + override fun asString(): StringPathObject = DefaultStringPathObject(channelObject) + + override fun asBoolean(): BooleanPathObject = DefaultBooleanPathObject(channelObject) + + override fun asBinary(): BinaryPathObject = DefaultBinaryPathObject(channelObject) + + override fun asJsonObject(): JsonObjectPathObject = DefaultJsonObjectPathObject(channelObject) + + override fun asJsonArray(): JsonArrayPathObject = DefaultJsonArrayPathObject(channelObject) + + override fun subscribe(listener: PathObjectListener): Subscription = subscribe(listener, null) + + override fun subscribe(listener: PathObjectListener, options: PathObjectSubscriptionOptions?): Subscription { + // TODO - subscribe logic goes here + return onceSubscription { + // TODO - remove PathObjectListener from list + } + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObjectSubscriptionEvent.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObjectSubscriptionEvent.kt new file mode 100644 index 000000000..6df14befc --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObjectSubscriptionEvent.kt @@ -0,0 +1,20 @@ +package io.ably.lib.`object`.path + +import io.ably.lib.`object`.message.ObjectMessage + +/** + * Default implementation of [PathObjectSubscriptionEvent], the event delivered to a + * [PathObjectListener] when a change affects the subscribed path. A plain holder for the + * changed [PathObject] and the source [ObjectMessage] (if any). + * + * Spec: RTPO19e / RTTS3d + */ +internal class DefaultPathObjectSubscriptionEvent( + private val objectAt: PathObject, + private val message: ObjectMessage?, +) : PathObjectSubscriptionEvent { + + override fun getObject(): PathObject = objectAt + + override fun getMessage(): ObjectMessage? = message +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt new file mode 100644 index 000000000..eacb2b8e5 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt @@ -0,0 +1,18 @@ +package io.ably.lib.`object`.path.types + +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.path.DefaultPathObject + +/** + * Default implementation of [BinaryPathObject], a terminal primitive view that only adds a + * type-narrowed [value]; left unimplemented for now. + * + * Spec: RTTS6c + */ +internal class DefaultBinaryPathObject( + channelObject: DefaultRealtimeObject, +) : DefaultPathObject(channelObject), BinaryPathObject { + + @Suppress("RedundantNullableReturnType") + override fun value(): ByteArray? = TODO("Not yet implemented") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt new file mode 100644 index 000000000..8616e2610 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt @@ -0,0 +1,18 @@ +package io.ably.lib.`object`.path.types + +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.path.DefaultPathObject + +/** + * Default implementation of [BooleanPathObject], a terminal primitive view that only adds a + * type-narrowed [value]; left unimplemented for now. + * + * Spec: RTTS6c + */ +internal class DefaultBooleanPathObject( + channelObject: DefaultRealtimeObject, +) : DefaultPathObject(channelObject), BooleanPathObject { + + @Suppress("RedundantNullableReturnType") + override fun value(): Boolean? = TODO("Not yet implemented") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt new file mode 100644 index 000000000..d52eb0d41 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt @@ -0,0 +1,19 @@ +package io.ably.lib.`object`.path.types + +import com.google.gson.JsonArray +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.path.DefaultPathObject + +/** + * Default implementation of [JsonArrayPathObject], a terminal primitive view that only adds + * a type-narrowed [value]; left unimplemented for now. + * + * Spec: RTTS6c + */ +internal class DefaultJsonArrayPathObject( + channelObject: DefaultRealtimeObject, +) : DefaultPathObject(channelObject), JsonArrayPathObject { + + @Suppress("RedundantNullableReturnType") + override fun value(): JsonArray? = TODO("Not yet implemented") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt new file mode 100644 index 000000000..f47426109 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt @@ -0,0 +1,19 @@ +package io.ably.lib.`object`.path.types + +import com.google.gson.JsonObject +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.path.DefaultPathObject + +/** + * Default implementation of [JsonObjectPathObject], a terminal primitive view that only adds + * a type-narrowed [value]; left unimplemented for now. + * + * Spec: RTTS6c + */ +internal class DefaultJsonObjectPathObject( + channelObject: DefaultRealtimeObject, +) : DefaultPathObject(channelObject), JsonObjectPathObject { + + @Suppress("RedundantNullableReturnType") + override fun value(): JsonObject? = TODO("Not yet implemented") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt new file mode 100644 index 000000000..2d6ec09ee --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt @@ -0,0 +1,29 @@ +package io.ably.lib.`object`.path.types + +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.path.DefaultPathObject +import java.util.concurrent.CompletableFuture + +/** + * Default implementation of [LiveCounterPathObject]. + * + * Counters are terminal nodes (no navigation), so this only adds the counter read/write + * operations on top of [DefaultPathObject]; they are left unimplemented for now. + * + * Spec: RTTS6b + */ +internal class DefaultLiveCounterPathObject( + channelObject: DefaultRealtimeObject, +) : DefaultPathObject(channelObject), LiveCounterPathObject { + + @Suppress("RedundantNullableReturnType") + override fun value(): Double? = TODO("Not yet implemented") + + override fun increment(): CompletableFuture = TODO("Not yet implemented") + + override fun increment(amount: Number): CompletableFuture = TODO("Not yet implemented") + + override fun decrement(): CompletableFuture = TODO("Not yet implemented") + + override fun decrement(amount: Number): CompletableFuture = TODO("Not yet implemented") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt new file mode 100644 index 000000000..91d1d1f75 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt @@ -0,0 +1,35 @@ +package io.ably.lib.`object`.path.types + +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.path.DefaultPathObject +import io.ably.lib.`object`.path.PathObject +import io.ably.lib.`object`.value.LiveMapValue +import java.util.concurrent.CompletableFuture + +/** + * Default implementation of [LiveMapPathObject], adding map navigation and read/write + * operations on top of [DefaultPathObject]; all left unimplemented for now. + * + * Spec: RTTS6a + */ +internal class DefaultLiveMapPathObject( + channelObject: DefaultRealtimeObject, +) : DefaultPathObject(channelObject), LiveMapPathObject { + + override fun get(key: String): PathObject = TODO("Not yet implemented") + + override fun at(path: String): PathObject = TODO("Not yet implemented") + + override fun entries(): Iterable> = TODO("Not yet implemented") + + override fun keys(): Iterable = TODO("Not yet implemented") + + override fun values(): Iterable = TODO("Not yet implemented") + + @Suppress("RedundantNullableReturnType") + override fun size(): Long? = TODO("Not yet implemented") + + override fun set(key: String, value: LiveMapValue): CompletableFuture = TODO("Not yet implemented") + + override fun remove(key: String): CompletableFuture = TODO("Not yet implemented") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt new file mode 100644 index 000000000..dd3e6d40e --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt @@ -0,0 +1,18 @@ +package io.ably.lib.`object`.path.types + +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.path.DefaultPathObject + +/** + * Default implementation of [NumberPathObject], a terminal primitive view that only adds a + * type-narrowed [value]; left unimplemented for now. + * + * Spec: RTTS6c + */ +internal class DefaultNumberPathObject( + channelObject: DefaultRealtimeObject, +) : DefaultPathObject(channelObject), NumberPathObject { + + @Suppress("RedundantNullableReturnType") + override fun value(): Number? = TODO("Not yet implemented") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt new file mode 100644 index 000000000..31671f83b --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt @@ -0,0 +1,18 @@ +package io.ably.lib.`object`.path.types + +import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.path.DefaultPathObject + +/** + * Default implementation of [StringPathObject], a terminal primitive view that only adds a + * type-narrowed [value]; left unimplemented for now. + * + * Spec: RTTS6c + */ +internal class DefaultStringPathObject( + channelObject: DefaultRealtimeObject, +) : DefaultPathObject(channelObject), StringPathObject { + + @Suppress("RedundantNullableReturnType") + override fun value(): String? = TODO("Not yet implemented") +} From 548c0b569462e99b431452e4376453dfe663b853 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 17 Jun 2026 15:58:06 +0530 Subject: [PATCH 09/40] Added impl. for DefaultObjectMessage and WireObjectMessage along with relevant error codes under `Errors.kt` --- .../main/kotlin/io/ably/lib/object/Errors.kt | 55 ++++ .../DefaultInstanceSubscriptionEvent.kt | 4 +- .../object/message/DefaultObjectMessage.kt | 146 +++++++++ .../lib/object/message/WireObjectMessage.kt | 297 ++++++++++++++++++ .../DefaultPathObjectSubscriptionEvent.kt | 4 +- 5 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/Errors.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/message/DefaultObjectMessage.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/Errors.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/Errors.kt new file mode 100644 index 000000000..8b2c3fcfd --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/Errors.kt @@ -0,0 +1,55 @@ +package io.ably.lib.`object` + +import io.ably.lib.types.AblyException +import io.ably.lib.types.ErrorInfo + +/** + * Error codes and helpers for the path-based public API implementation. + * Copied (and extended with the path-API codes) from the legacy package so + * this package has no dependency on `io.ably.lib.objects`. + */ +internal enum class ObjectErrorCode(val code: Int) { + BadRequest(40_000), + InternalError(50_000), + MaxMessageSizeExceeded(40_009), + InvalidObject(92_000), + InvalidInputParams(40_003), + MapValueDataTypeUnsupported(40_013), + PathNotResolved(92_005), // RTPO3c2 - write operation on a path that does not resolve + ObjectsTypeMismatch(92_007), // RTTS5d2/RTTS9d2 - operation on a cast wrapper with mismatched resolved type + // Channel mode and state validation error codes + ChannelModeRequired(40_024), + ChannelStateError(90_001), + PublishAndApplyFailedDueToChannelState(92_008), +} + +internal enum class ObjectHttpStatusCode(val code: Int) { + BadRequest(400), + InternalServerError(500), +} + +internal fun objectException( + errorMessage: String, + errorCode: ObjectErrorCode, + statusCode: ObjectHttpStatusCode = ObjectHttpStatusCode.BadRequest, + cause: Throwable? = null, +): AblyException { + val errorInfo = ErrorInfo(errorMessage, statusCode.code, errorCode.code) + return cause?.let { AblyException.fromErrorInfo(it, errorInfo) } ?: AblyException.fromErrorInfo(errorInfo) +} + +/** ErrorInfo 400 / 40003 - invalid input (RTLMV4a/b, RTLCV4a, key validation). */ +internal fun invalidInputError(message: String) = + objectException(message, ObjectErrorCode.InvalidInputParams) + +/** ErrorInfo 400 / 92005 - write operation on an unresolvable path (RTPO3c2). */ +internal fun pathNotResolvedError(path: String) = + objectException("Path could not be resolved: \"$path\"", ObjectErrorCode.PathNotResolved) + +/** ErrorInfo 400 / 92007 - resolved/wrapped type does not match the typed wrapper (RTTS5d2/RTTS9d2). */ +internal fun typeMismatchError(message: String) = + objectException(message, ObjectErrorCode.ObjectsTypeMismatch) + +/** ErrorInfo 500 / 92000 - invalid internal object state. */ +internal fun objectStateError(message: String) = + objectException(message, ObjectErrorCode.InvalidObject, ObjectHttpStatusCode.InternalServerError) diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstanceSubscriptionEvent.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstanceSubscriptionEvent.kt index aaa47108c..292eb5ad2 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstanceSubscriptionEvent.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstanceSubscriptionEvent.kt @@ -10,11 +10,11 @@ import io.ably.lib.`object`.message.ObjectMessage * Spec: RTINS16e */ internal class DefaultInstanceSubscriptionEvent( - private val objectAt: Instance, + private val instance: Instance, private val message: ObjectMessage?, ) : InstanceSubscriptionEvent { - override fun getObject(): Instance = objectAt + override fun getObject(): Instance = instance override fun getMessage(): ObjectMessage? = message } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/message/DefaultObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/message/DefaultObjectMessage.kt new file mode 100644 index 000000000..f75f2ef2c --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/message/DefaultObjectMessage.kt @@ -0,0 +1,146 @@ +package io.ably.lib.`object`.message + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import io.ably.lib.`object`.objectStateError +import java.util.* + +/** + * Builds the user-facing PublicAPI::ObjectMessage from an inbound wire + * ObjectMessage that carried an operation. Mirrors ably-js + * `objectmessage.ts#toUserFacingMessage`. + * + * Precondition (PAOM3a1): the source message has its `operation` populated. + * + * Spec: PAOM3 + */ +internal fun WireObjectMessage.toPublicMessage(channelName: String): ObjectMessage = + DefaultObjectMessage(this, channelName) + +/** + * PublicAPI::ObjectMessage implementation - a read-only view over the source + * wire message. Spec: PAOM1, PAOM2 + */ +internal class DefaultObjectMessage( + private val message: WireObjectMessage, + private val channelName: String, +) : ObjectMessage { + + private val operation: ObjectOperation = DefaultObjectOperation( + message.operation ?: throw objectStateError("Cannot build public ObjectMessage without an operation") // PAOM3a1 + ) + + override fun getId(): String? = message.id // PAOM2a + override fun getClientId(): String? = message.clientId // PAOM2b + override fun getConnectionId(): String? = message.connectionId // PAOM2c + override fun getTimestamp(): Long? = message.timestamp // PAOM2d + override fun getChannel(): String = channelName // PAOM2e, PAOM3b + override fun getOperation(): ObjectOperation = operation // PAOM2f + override fun getSerial(): String? = message.serial // PAOM2g + override fun getSerialTimestamp(): Long? = message.serialTimestamp // PAOM2h + override fun getSiteCode(): String? = message.siteCode // PAOM2i + override fun getExtras(): JsonObject? = message.extras // PAOM2j +} + +/** + * PublicAPI::ObjectOperation implementation. Resolves the outbound-only + * `*CreateWithObjectId` variants back to their derived MapCreate/CounterCreate + * forms. Spec: PAOOP1, PAOOP2, PAOOP3 + */ +internal class DefaultObjectOperation(private val operation: WireObjectOperation) : ObjectOperation { + + override fun getAction(): ObjectOperationAction = operation.action.toPublic() // PAOOP2a + + override fun getObjectId(): String = operation.objectId // PAOOP2b + + // PAOOP3b - prefer mapCreate, else the MapCreate the WithObjectId variant was derived from + override fun getMapCreate(): MapCreate? = + (operation.mapCreate ?: operation.mapCreateWithObjectId?.derivedFrom)?.let { DefaultMapCreate(it) } + + override fun getMapSet(): MapSet? = operation.mapSet?.let { DefaultMapSet(it) } // PAOOP2d + + override fun getMapRemove(): MapRemove? = operation.mapRemove?.let { DefaultMapRemove(it) } // PAOOP2e + + // PAOOP3c - prefer counterCreate, else the derived CounterCreate + override fun getCounterCreate(): CounterCreate? = + (operation.counterCreate ?: operation.counterCreateWithObjectId?.derivedFrom)?.let { DefaultCounterCreate(it) } + + override fun getCounterInc(): CounterInc? = operation.counterInc?.let { DefaultCounterInc(it) } // PAOOP2g + + override fun getObjectDelete(): ObjectDelete? = operation.objectDelete?.let { DefaultObjectDelete } // PAOOP2h + + override fun getMapClear(): MapClear? = operation.mapClear?.let { DefaultMapClear } // PAOOP2i +} + +/** Spec: MCR2 */ +internal class DefaultMapCreate(private val mapCreate: WireMapCreate) : MapCreate { + override fun getSemantics(): ObjectsMapSemantics = mapCreate.semantics.toPublic() + override fun getEntries(): Map = + Collections.unmodifiableMap(mapCreate.entries.mapValues { (_, entry) -> DefaultObjectsMapEntry(entry) }) +} + +/** Spec: MST2 */ +internal class DefaultMapSet(private val mapSet: WireMapSet) : MapSet { + override fun getKey(): String = mapSet.key + override fun getValue(): ObjectData = DefaultObjectData(mapSet.value) +} + +/** Spec: MRM2 */ +internal class DefaultMapRemove(private val mapRemove: WireMapRemove) : MapRemove { + override fun getKey(): String = mapRemove.key +} + +/** Spec: CCR2 */ +internal class DefaultCounterCreate(private val counterCreate: WireCounterCreate) : CounterCreate { + override fun getCount(): Double = counterCreate.count +} + +/** Spec: CIN2 */ +internal class DefaultCounterInc(private val counterInc: WireCounterInc) : CounterInc { + override fun getNumber(): Double = counterInc.number +} + +/** Spec: ODE2 - no attributes */ +internal object DefaultObjectDelete : ObjectDelete + +/** Spec: MCL2 - no attributes */ +internal object DefaultMapClear : MapClear + +/** Spec: OME2 */ +internal class DefaultObjectsMapEntry(private val entry: WireObjectsMapEntry) : ObjectsMapEntry { + override fun getTombstone(): Boolean? = entry.tombstone + override fun getTimeserial(): String? = entry.timeserial + override fun getSerialTimestamp(): Long? = entry.serialTimestamp + override fun getData(): ObjectData? = entry.data?.let { DefaultObjectData(it) } +} + +/** + * Decoded public ObjectData: binary is delivered decoded (the wire form is + * base64); there is no `encoding` field in the public shape. Spec: OD2 + */ +internal class DefaultObjectData(private val data: WireObjectData) : ObjectData { + override fun getObjectId(): String? = data.objectId + override fun getString(): String? = data.string + override fun getNumber(): Double? = data.number + override fun getBoolean(): Boolean? = data.boolean + override fun getBytes(): ByteArray? = data.bytes?.let { Base64.getDecoder().decode(it) } + override fun getJson(): JsonElement? = data.json +} + +/** Internal action -> public enum; unrecognized wire values map to UNKNOWN. Spec: PAOOP2a, OOP2 */ +internal fun WireObjectOperationAction.toPublic(): ObjectOperationAction = when (this) { + WireObjectOperationAction.MapCreate -> ObjectOperationAction.MAP_CREATE + WireObjectOperationAction.MapSet -> ObjectOperationAction.MAP_SET + WireObjectOperationAction.MapRemove -> ObjectOperationAction.MAP_REMOVE + WireObjectOperationAction.CounterCreate -> ObjectOperationAction.COUNTER_CREATE + WireObjectOperationAction.CounterInc -> ObjectOperationAction.COUNTER_INC + WireObjectOperationAction.ObjectDelete -> ObjectOperationAction.OBJECT_DELETE + WireObjectOperationAction.MapClear -> ObjectOperationAction.MAP_CLEAR + WireObjectOperationAction.Unknown -> ObjectOperationAction.UNKNOWN +} + +/** Internal semantics -> public enum. Spec: OMP2 */ +internal fun WireObjectsMapSemantics.toPublic(): ObjectsMapSemantics = when (this) { + WireObjectsMapSemantics.LWW -> ObjectsMapSemantics.LWW + WireObjectsMapSemantics.Unknown -> ObjectsMapSemantics.UNKNOWN +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt new file mode 100644 index 000000000..6d8ccd785 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt @@ -0,0 +1,297 @@ +package io.ably.lib.`object`.message + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import java.nio.charset.StandardCharsets +import java.util.Base64 + +/** + * Wire-level object model for the path-based public API implementation. + * + * Copied from the legacy internal model (`io.ably.lib.objects.ObjectMessage`) + * so that this package has no dependency on `io.ably.lib.objects`. The `Wire` + * prefix distinguishes these internal carriers from the public interfaces in + * `io.ably.lib.object.message`. + * + * Spec: OM*, OOP*, OD*, MCR*, MST*, MRM*, CCR*, CIN*, ODE*, MCL*, OME*, MCRO*, CCRO*, OMP*, OCN*, OST* + */ + +/** Spec: OOP2 */ +internal enum class WireObjectOperationAction(val code: Int) { + MapCreate(0), + MapSet(1), + MapRemove(2), + CounterCreate(3), + CounterInc(4), + ObjectDelete(5), + MapClear(6), + Unknown(-1); // code for unknown value during deserialization +} + +/** Spec: OMP2 */ +internal enum class WireObjectsMapSemantics(val code: Int) { + LWW(0), + Unknown(-1); // code for unknown value during deserialization +} + +/** Spec: OD1, OD2 - binary carried as base64 string on the wire */ +internal data class WireObjectData( + val objectId: String? = null, // OD2a + val string: String? = null, // OD2f + val number: Double? = null, // OD2e + val boolean: Boolean? = null, // OD2c + val bytes: String? = null, // OD2d - base64 + val json: JsonElement? = null, // decoded JSON leaf +) + +/** Spec: MCR2 */ +internal data class WireMapCreate( + val semantics: WireObjectsMapSemantics, // MCR2a + val entries: Map, // MCR2b +) + +/** Spec: MST2 */ +internal data class WireMapSet( + val key: String, // MST2a + val value: WireObjectData, // MST2b +) + +/** Spec: MRM2 */ +internal data class WireMapRemove( + val key: String, // MRM2a +) + +/** Spec: CCR2 */ +internal data class WireCounterCreate( + val count: Double, // CCR2a +) + +/** Spec: CIN2 */ +internal data class WireCounterInc( + val number: Double, // CIN2a +) + +/** Spec: ODE2 - no attributes */ +internal object WireObjectDelete + +/** Spec: MCL2 - no attributes */ +internal object WireMapClear + +/** Spec: MCRO2 */ +internal data class WireMapCreateWithObjectId( + val initialValue: String, // MCRO2a + val nonce: String, // MCRO2b + @Transient val derivedFrom: WireMapCreate? = null, // RTLMV4j5 - local use only +) + +/** Spec: CCRO2 */ +internal data class WireCounterCreateWithObjectId( + val initialValue: String, // CCRO2a + val nonce: String, // CCRO2b + @Transient val derivedFrom: WireCounterCreate? = null, // RTLCV4g5 - local use only +) + +/** Spec: OME2 */ +internal data class WireObjectsMapEntry( + val tombstone: Boolean? = null, // OME2a + val timeserial: String? = null, // OME2b + val serialTimestamp: Long? = null, // OME2d + val data: WireObjectData? = null, // OME2c +) + +/** Spec: OMP1 */ +internal data class WireObjectsMap( + val semantics: WireObjectsMapSemantics? = null, // OMP3a + val entries: Map? = null, // OMP3b + val clearTimeserial: String? = null, // OMP3c +) + +/** Spec: OCN1 */ +internal data class WireObjectsCounter( + val count: Double? = null, // OCN2a +) + +/** Spec: OOP3 */ +internal data class WireObjectOperation( + val action: WireObjectOperationAction, // OOP3a + val objectId: String, // OOP3b + val mapCreate: WireMapCreate? = null, // OOP3j + val mapSet: WireMapSet? = null, // OOP3k + val mapRemove: WireMapRemove? = null, // OOP3l + val counterCreate: WireCounterCreate? = null, // OOP3m + val counterInc: WireCounterInc? = null, // OOP3n + val objectDelete: WireObjectDelete? = null, // OOP3o + val mapCreateWithObjectId: WireMapCreateWithObjectId? = null, // OOP3p + val counterCreateWithObjectId: WireCounterCreateWithObjectId? = null, // OOP3q + val mapClear: WireMapClear? = null, // OOP3r +) + +/** Spec: OST1 */ +internal data class WireObjectState( + val objectId: String, // OST2a + val siteTimeserials: Map, // OST2b + val tombstone: Boolean, // OST2c + val createOp: WireObjectOperation? = null, // OST2d + val map: WireObjectsMap? = null, // OST2e + val counter: WireObjectsCounter? = null, // OST2f +) + +/** Spec: OM2 */ +internal data class WireObjectMessage( + val id: String? = null, // OM2a + val timestamp: Long? = null, // OM2e + val clientId: String? = null, // OM2b + val connectionId: String? = null, // OM2c + val extras: JsonObject? = null, // OM2d + val operation: WireObjectOperation? = null, // OM2f + val objectState: WireObjectState? = null, // OM2g - wire key "object" + val serial: String? = null, // OM2h + val serialTimestamp: Long? = null, // OM2j + val siteCode: String? = null, // OM2i +) + +// Gson instance for serializing the opaque `extras` field during size calculation. +// Kept file-local so this package has no dependency on `io.ably.lib.objects`. +private val gson = Gson() + +/** + * Calculates the byte size of a string. + * For non-ASCII, the byte size can be 2–4x the character count. For ASCII, there is no difference. + * e.g. "Hello" has a byte size of 5, while "你" has a byte size of 3 and "😊" has a byte size of 4. + */ +private val String.byteSize: Int + get() = this.toByteArray(StandardCharsets.UTF_8).size + +/** + * Calculates the size of an ObjectMessage in bytes. + * Spec: OM3 + */ +internal fun WireObjectMessage.size(): Int { + val clientIdSize = clientId?.byteSize ?: 0 // Spec: OM3f + val operationSize = operation?.size() ?: 0 // Spec: OM3b, OOP4 + val objectStateSize = objectState?.size() ?: 0 // Spec: OM3c, OST3 + val extrasSize = extras?.let { gson.toJson(it).length } ?: 0 // Spec: OM3d + + return clientIdSize + operationSize + objectStateSize + extrasSize +} + +/** + * Calculates the size of an ObjectOperation in bytes. + * Spec: OOP4 + */ +private fun WireObjectOperation.size(): Int { + val mapCreateSize = mapCreate?.size() ?: mapCreateWithObjectId?.derivedFrom?.size() ?: 0 + val mapSetSize = mapSet?.size() ?: 0 + val mapRemoveSize = mapRemove?.size() ?: 0 + val counterCreateSize = counterCreate?.size() ?: counterCreateWithObjectId?.derivedFrom?.size() ?: 0 + val counterIncSize = counterInc?.size() ?: 0 + + return mapCreateSize + mapSetSize + mapRemoveSize + + counterCreateSize + counterIncSize +} + +/** + * Calculates the size of an ObjectState in bytes. + * Spec: OST3 + */ +private fun WireObjectState.size(): Int { + val mapSize = map?.size() ?: 0 // Spec: OST3b, OMP4 + val counterSize = counter?.size() ?: 0 // Spec: OST3c, OCN3 + val createOpSize = createOp?.size() ?: 0 // Spec: OST3d, OOP4 + + return mapSize + counterSize + createOpSize +} + +/** + * Calculates the size of a MapCreate payload in bytes. + */ +private fun WireMapCreate.size(): Int { + return entries.entries.sumOf { it.key.byteSize + it.value.size() } +} + +/** + * Calculates the size of a MapSet payload in bytes. + */ +private fun WireMapSet.size(): Int { + return key.byteSize + value.size() +} + +/** + * Calculates the size of a MapRemove payload in bytes. + */ +private fun WireMapRemove.size(): Int { + return key.byteSize +} + +/** + * Calculates the size of a CounterCreate payload in bytes. + */ +private fun WireCounterCreate.size(): Int { + return 8 // Double is 8 bytes +} + +/** + * Calculates the size of a CounterInc payload in bytes. + */ +private fun WireCounterInc.size(): Int { + return 8 // Double is 8 bytes +} + +/** + * Calculates the size of a MapCreateWithObjectId payload in bytes. + */ +private fun WireMapCreateWithObjectId.size(): Int { + return initialValue.byteSize + nonce.byteSize +} + +/** + * Calculates the size of a CounterCreateWithObjectId payload in bytes. + */ +private fun WireCounterCreateWithObjectId.size(): Int { + return initialValue.byteSize + nonce.byteSize +} + +/** + * Calculates the size of an ObjectMap in bytes. + * Spec: OMP4 + */ +private fun WireObjectsMap.size(): Int { + // Calculate the size of all map entries in the map property + val entriesSize = entries?.entries?.sumOf { + it.key.length + it.value.size() // // Spec: OMP4a1, OMP4a2 + } ?: 0 + + return entriesSize +} + +/** + * Calculates the size of an ObjectCounter in bytes. + * Spec: OCN3 + */ +private fun WireObjectsCounter.size(): Int { + // Size is 8 if count is a number, 0 if count is null or omitted + return if (count != null) 8 else 0 +} + +/** + * Calculates the size of a MapEntry in bytes. + * Spec: OME3 + */ +private fun WireObjectsMapEntry.size(): Int { + // The size is equal to the size of the data property, calculated per "OD3" + return data?.size() ?: 0 +} + +/** + * Calculates the size of an ObjectData in bytes. + * Spec: OD3 + */ +private fun WireObjectData.size(): Int { + string?.let { return it.byteSize } // Spec: OD3e + number?.let { return 8 } // Spec: OD3d + boolean?.let { return 1 } // Spec: OD3b + bytes?.let { return Base64.getDecoder().decode(it).size } // Spec: OD3c + json?.let { return it.toString().byteSize } // Spec: OD3e + return 0 +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObjectSubscriptionEvent.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObjectSubscriptionEvent.kt index 6df14befc..8a73882be 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObjectSubscriptionEvent.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObjectSubscriptionEvent.kt @@ -10,11 +10,11 @@ import io.ably.lib.`object`.message.ObjectMessage * Spec: RTPO19e / RTTS3d */ internal class DefaultPathObjectSubscriptionEvent( - private val objectAt: PathObject, + private val pathObject: PathObject, private val message: ObjectMessage?, ) : PathObjectSubscriptionEvent { - override fun getObject(): PathObject = objectAt + override fun getObject(): PathObject = pathObject override fun getMessage(): ObjectMessage? = message } From af0b39eacac7fdbca953af0c209b83634235b528 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 17 Jun 2026 16:29:17 +0530 Subject: [PATCH 10/40] Added default skeleton implementation for LiveCounter and LiveMap --- .../io/ably/lib/object/value/LiveCounter.java | 2 +- .../io/ably/lib/object/value/LiveMap.java | 2 +- .../lib/object/value/DefaultLiveCounter.kt | 22 +++++++++++++++++ .../ably/lib/object/value/DefaultLiveMap.kt | 24 +++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveCounter.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveMap.kt diff --git a/lib/src/main/java/io/ably/lib/object/value/LiveCounter.java b/lib/src/main/java/io/ably/lib/object/value/LiveCounter.java index 95f9e45b9..dfd3b785d 100644 --- a/lib/src/main/java/io/ably/lib/object/value/LiveCounter.java +++ b/lib/src/main/java/io/ably/lib/object/value/LiveCounter.java @@ -23,7 +23,7 @@ */ public abstract class LiveCounter { - private static final String IMPLEMENTATION_CLASS = "io.ably.lib.object.DefaultLiveCounter"; + private static final String IMPLEMENTATION_CLASS = "io.ably.lib.object.value.DefaultLiveCounter"; /** * Extended by the LiveObjects implementation; not intended for diff --git a/lib/src/main/java/io/ably/lib/object/value/LiveMap.java b/lib/src/main/java/io/ably/lib/object/value/LiveMap.java index 810149b9c..c43f76a96 100644 --- a/lib/src/main/java/io/ably/lib/object/value/LiveMap.java +++ b/lib/src/main/java/io/ably/lib/object/value/LiveMap.java @@ -26,7 +26,7 @@ */ public abstract class LiveMap { - private static final String IMPLEMENTATION_CLASS = "io.ably.lib.object.DefaultLiveMap"; + private static final String IMPLEMENTATION_CLASS = "io.ably.lib.object.value.DefaultLiveMap"; /** * Extended by the LiveObjects implementation; not intended for diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveCounter.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveCounter.kt new file mode 100644 index 000000000..43fec3909 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveCounter.kt @@ -0,0 +1,22 @@ +package io.ably.lib.`object`.value + +/** + * Default implementation of the [LiveCounter] value type - an immutable holder for + * the initial count of a LiveCounter object to be created. Mirrors ably-js + * `LiveCounterValueType`. + * + * Instantiated reflectively by [LiveCounter.create] through the constructor that + * takes the initial count; the count is retained internally with no public accessor + * (Spec: RTLCV3d). + * + * This is currently a skeleton: it only retains the initial value. Producing the + * `COUNTER_CREATE` operation/message from this count is not yet implemented. + * + * Spec: RTLCV1, RTLCV2, RTLCV3 + */ +internal class DefaultLiveCounter( + internal val initialCount: Number, +) : LiveCounter() { + // TODO - build the COUNTER_CREATE ObjectMessage from `initialCount`, mirroring + // ably-js LiveCounterValueType.createCounterCreateMessage. Spec: RTO12f +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveMap.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveMap.kt new file mode 100644 index 000000000..4f6520b39 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveMap.kt @@ -0,0 +1,24 @@ +package io.ably.lib.`object`.value + +/** + * Default implementation of the [LiveMap] value type - an immutable holder for the + * initial entries of a LiveMap object to be created. Mirrors ably-js + * `LiveMapValueType`. + * + * Instantiated reflectively by [LiveMap.create] through the constructor that takes + * the initial entries map; the entries are retained internally with no public + * accessor (Spec: RTLMV3d). + * + * This is currently a skeleton: it only retains the initial value. Producing the + * `MAP_CREATE` operation/message from these entries (including nested object create + * messages for nested [LiveMap]/[LiveCounter] value types) is not yet implemented. + * + * Spec: RTLMV1, RTLMV2, RTLMV3 + */ +internal class DefaultLiveMap( + internal val entries: Map, +) : LiveMap() { + // TODO - build the MAP_CREATE ObjectMessage (plus nested object create messages) + // from `entries`, mirroring ably-js LiveMapValueType.createMapCreateMessage. + // Spec: RTO11f +} From f74ae1d8ece47e34b38f197c70965692bbae4f3f Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 17 Jun 2026 16:50:40 +0530 Subject: [PATCH 11/40] Updated instance types to return specific JsonPrimitive/JsonObject/JsonArray --- .../lib/object/instance/types/BinaryInstance.java | 12 ++++++++++++ .../lib/object/instance/types/BooleanInstance.java | 13 +++++++++++++ .../object/instance/types/JsonArrayInstance.java | 11 +++++++++++ .../object/instance/types/JsonObjectInstance.java | 11 +++++++++++ .../object/instance/types/LiveCounterInstance.java | 13 +++++++++++++ .../lib/object/instance/types/LiveMapInstance.java | 13 +++++++++++++ .../lib/object/instance/types/NumberInstance.java | 13 +++++++++++++ .../lib/object/instance/types/StringInstance.java | 13 +++++++++++++ .../object/instance/types/DefaultBinaryInstance.kt | 4 ++-- .../object/instance/types/DefaultBooleanInstance.kt | 4 ++-- .../instance/types/DefaultJsonArrayInstance.kt | 3 +-- .../instance/types/DefaultJsonObjectInstance.kt | 3 +-- .../instance/types/DefaultLiveCounterInstance.kt | 4 ++-- .../object/instance/types/DefaultLiveMapInstance.kt | 4 ++-- .../object/instance/types/DefaultNumberInstance.kt | 4 ++-- .../object/instance/types/DefaultStringInstance.kt | 4 ++-- 16 files changed, 113 insertions(+), 16 deletions(-) diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java index 91e8b7023..f4860d1ae 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java @@ -1,5 +1,6 @@ package io.ably.lib.object.instance.types; +import com.google.gson.JsonPrimitive; import io.ably.lib.object.instance.Instance; import org.jetbrains.annotations.NotNull; @@ -22,4 +23,15 @@ public interface BinaryInstance extends Instance { * @return the wrapped bytes */ byte @NotNull [] value(); + + /** + * Returns the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonPrimitive}: binary compacts to a base64-encoded JSON string. + * + *

Spec: RTTS7a + * + * @return the compacted JSON primitive + */ + @Override + @NotNull JsonPrimitive compactJson(); } diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java index c4ec1a01e..380a17812 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java @@ -1,5 +1,6 @@ package io.ably.lib.object.instance.types; +import com.google.gson.JsonPrimitive; import io.ably.lib.object.instance.Instance; import org.jetbrains.annotations.NotNull; @@ -22,4 +23,16 @@ public interface BooleanInstance extends Instance { */ @NotNull Boolean value(); + + /** + * Returns the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonPrimitive}: a {@code BooleanInstance} always compacts to a single + * JSON primitive. + * + *

Spec: RTTS7a + * + * @return the compacted JSON primitive + */ + @Override + @NotNull JsonPrimitive compactJson(); } diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java index f85fc0865..7df1f929f 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java @@ -23,4 +23,15 @@ public interface JsonArrayInstance extends Instance { */ @NotNull JsonArray value(); + + /** + * Returns the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonArray}: a {@code JsonArrayInstance} always compacts to a JSON array. + * + *

Spec: RTTS7a + * + * @return the compacted JSON array + */ + @Override + @NotNull JsonArray compactJson(); } diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java index 7fce7183d..07222a11d 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java @@ -23,4 +23,15 @@ public interface JsonObjectInstance extends Instance { */ @NotNull JsonObject value(); + + /** + * Returns the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonObject}: a {@code JsonObjectInstance} always compacts to a JSON object. + * + *

Spec: RTTS7a + * + * @return the compacted JSON object + */ + @Override + @NotNull JsonObject compactJson(); } diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java index c80b91f91..f5296ccf9 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java @@ -1,5 +1,6 @@ package io.ably.lib.object.instance.types; +import com.google.gson.JsonPrimitive; import io.ably.lib.object.instance.Instance; import io.ably.lib.object.instance.InstanceListener; import io.ably.lib.object.Subscription; @@ -37,6 +38,18 @@ public interface LiveCounterInstance extends Instance { @NotNull Double value(); + /** + * Returns the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonPrimitive}: a {@code LiveCounterInstance} always compacts to a numeric + * JSON primitive. + * + *

Spec: RTTS7a + * + * @return the compacted JSON primitive + */ + @Override + @NotNull JsonPrimitive compactJson(); + /** * Increments the wrapped {@code LiveCounter} by {@code 1}. Equivalent to * calling {@link #increment(Number)} with {@code 1}. diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java index a6c3fb2d4..c5b79bc1c 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java @@ -1,5 +1,6 @@ package io.ably.lib.object.instance.types; +import com.google.gson.JsonObject; import io.ably.lib.object.instance.Instance; import io.ably.lib.object.instance.InstanceListener; import io.ably.lib.object.Subscription; @@ -34,6 +35,18 @@ public interface LiveMapInstance extends Instance { @NotNull String getId(); + /** + * Returns the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonObject}: a {@code LiveMapInstance} compacts to a JSON object (or, for a + * cyclic reference, an object-id reference object). + * + *

Spec: RTTS7a + * + * @return the compacted JSON object + */ + @Override + @NotNull JsonObject compactJson(); + /** * Returns a {@link Instance} wrapping the value at {@code key} of the * wrapped {@code LiveMap}, or {@code null} when the key is absent / tombstoned. diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java index 4e94637f5..298fd59f5 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java @@ -1,5 +1,6 @@ package io.ably.lib.object.instance.types; +import com.google.gson.JsonPrimitive; import io.ably.lib.object.instance.Instance; import org.jetbrains.annotations.NotNull; @@ -22,4 +23,16 @@ public interface NumberInstance extends Instance { */ @NotNull Number value(); + + /** + * Returns the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonPrimitive}: a {@code NumberInstance} always compacts to a single + * JSON primitive. + * + *

Spec: RTTS7a + * + * @return the compacted JSON primitive + */ + @Override + @NotNull JsonPrimitive compactJson(); } diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java b/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java index 06e39a417..a7a06de15 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java @@ -1,5 +1,6 @@ package io.ably.lib.object.instance.types; +import com.google.gson.JsonPrimitive; import io.ably.lib.object.instance.Instance; import org.jetbrains.annotations.NotNull; @@ -22,4 +23,16 @@ public interface StringInstance extends Instance { */ @NotNull String value(); + + /** + * Returns the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonPrimitive}: a {@code StringInstance} always compacts to a single + * JSON primitive. + * + *

Spec: RTTS7a + * + * @return the compacted JSON primitive + */ + @Override + @NotNull JsonPrimitive compactJson(); } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBinaryInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBinaryInstance.kt index 6ef67dfaa..bd4219a81 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBinaryInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBinaryInstance.kt @@ -1,6 +1,6 @@ package io.ably.lib.`object`.instance.types -import com.google.gson.JsonElement +import com.google.gson.JsonPrimitive import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.ValueType import io.ably.lib.`object`.instance.DefaultInstance @@ -17,7 +17,7 @@ internal class DefaultBinaryInstance( override fun getType(): ValueType = ValueType.BINARY - override fun compactJson(): JsonElement = TODO("Not yet implemented") + override fun compactJson(): JsonPrimitive = TODO("Not yet implemented") override fun asBinary(): BinaryInstance = this diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBooleanInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBooleanInstance.kt index 9971be07a..26bc2de67 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBooleanInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBooleanInstance.kt @@ -1,6 +1,6 @@ package io.ably.lib.`object`.instance.types -import com.google.gson.JsonElement +import com.google.gson.JsonPrimitive import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.ValueType import io.ably.lib.`object`.instance.DefaultInstance @@ -17,7 +17,7 @@ internal class DefaultBooleanInstance( override fun getType(): ValueType = ValueType.BOOLEAN - override fun compactJson(): JsonElement = TODO("Not yet implemented") + override fun compactJson(): JsonPrimitive = TODO("Not yet implemented") override fun asBoolean(): BooleanInstance = this diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonArrayInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonArrayInstance.kt index 2cebbdfed..47fe65e8e 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonArrayInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonArrayInstance.kt @@ -1,7 +1,6 @@ package io.ably.lib.`object`.instance.types import com.google.gson.JsonArray -import com.google.gson.JsonElement import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.ValueType import io.ably.lib.`object`.instance.DefaultInstance @@ -18,7 +17,7 @@ internal class DefaultJsonArrayInstance( override fun getType(): ValueType = ValueType.JSON_ARRAY - override fun compactJson(): JsonElement = TODO("Not yet implemented") + override fun compactJson(): JsonArray = TODO("Not yet implemented") override fun asJsonArray(): JsonArrayInstance = this diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonObjectInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonObjectInstance.kt index 36c00fff1..555e8736b 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonObjectInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonObjectInstance.kt @@ -1,6 +1,5 @@ package io.ably.lib.`object`.instance.types -import com.google.gson.JsonElement import com.google.gson.JsonObject import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.ValueType @@ -18,7 +17,7 @@ internal class DefaultJsonObjectInstance( override fun getType(): ValueType = ValueType.JSON_OBJECT - override fun compactJson(): JsonElement = TODO("Not yet implemented") + override fun compactJson(): JsonObject = TODO("Not yet implemented") override fun asJsonObject(): JsonObjectInstance = this diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveCounterInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveCounterInstance.kt index b90e1330f..50d647fcd 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveCounterInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveCounterInstance.kt @@ -1,6 +1,6 @@ package io.ably.lib.`object`.instance.types -import com.google.gson.JsonElement +import com.google.gson.JsonPrimitive import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.Subscription import io.ably.lib.`object`.ValueType @@ -21,7 +21,7 @@ internal class DefaultLiveCounterInstance( override fun getType(): ValueType = ValueType.LIVE_COUNTER - override fun compactJson(): JsonElement = TODO("Not yet implemented") + override fun compactJson(): JsonPrimitive = TODO("Not yet implemented") override fun asLiveCounter(): LiveCounterInstance = this diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveMapInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveMapInstance.kt index 816cc202f..351bfd8c4 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveMapInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveMapInstance.kt @@ -1,6 +1,6 @@ package io.ably.lib.`object`.instance.types -import com.google.gson.JsonElement +import com.google.gson.JsonObject import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.Subscription import io.ably.lib.`object`.ValueType @@ -23,7 +23,7 @@ internal class DefaultLiveMapInstance( override fun getType(): ValueType = ValueType.LIVE_MAP - override fun compactJson(): JsonElement = TODO("Not yet implemented") + override fun compactJson(): JsonObject = TODO("Not yet implemented") override fun asLiveMap(): LiveMapInstance = this diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultNumberInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultNumberInstance.kt index 230e7250a..fc57d3bf3 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultNumberInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultNumberInstance.kt @@ -1,6 +1,6 @@ package io.ably.lib.`object`.instance.types -import com.google.gson.JsonElement +import com.google.gson.JsonPrimitive import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.ValueType import io.ably.lib.`object`.instance.DefaultInstance @@ -17,7 +17,7 @@ internal class DefaultNumberInstance( override fun getType(): ValueType = ValueType.NUMBER - override fun compactJson(): JsonElement = TODO("Not yet implemented") + override fun compactJson(): JsonPrimitive = TODO("Not yet implemented") override fun asNumber(): NumberInstance = this diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultStringInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultStringInstance.kt index c1a392269..36194090c 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultStringInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultStringInstance.kt @@ -1,6 +1,6 @@ package io.ably.lib.`object`.instance.types -import com.google.gson.JsonElement +import com.google.gson.JsonPrimitive import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.ValueType import io.ably.lib.`object`.instance.DefaultInstance @@ -17,7 +17,7 @@ internal class DefaultStringInstance( override fun getType(): ValueType = ValueType.STRING - override fun compactJson(): JsonElement = TODO("Not yet implemented") + override fun compactJson(): JsonPrimitive = TODO("Not yet implemented") override fun asString(): StringInstance = this From 54ae53f0f1eaad4d3fdb94e75919c1b843f6d76b Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 17 Jun 2026 17:21:58 +0530 Subject: [PATCH 12/40] - Implemented ResolvedValue class for resolving value at given path - Marked PathObject#getValue as nullable when value doesn't exist at given path --- .../java/io/ably/lib/object/ValueType.java | 2 +- .../io/ably/lib/object/path/PathObject.java | 17 +++++--- .../ably/lib/object/path/DefaultPathObject.kt | 30 ++++++++------ .../path/types/DefaultBinaryPathObject.kt | 3 +- .../path/types/DefaultBooleanPathObject.kt | 3 +- .../path/types/DefaultJsonArrayPathObject.kt | 3 +- .../path/types/DefaultJsonObjectPathObject.kt | 3 +- .../types/DefaultLiveCounterPathObject.kt | 3 +- .../path/types/DefaultLiveMapPathObject.kt | 3 +- .../path/types/DefaultNumberPathObject.kt | 3 +- .../path/types/DefaultStringPathObject.kt | 3 +- .../io/ably/lib/object/value/ResolvedValue.kt | 39 +++++++++++++++++++ 12 files changed, 86 insertions(+), 26 deletions(-) create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/value/ResolvedValue.kt diff --git a/lib/src/main/java/io/ably/lib/object/ValueType.java b/lib/src/main/java/io/ably/lib/object/ValueType.java index c045a075c..1491d9c36 100644 --- a/lib/src/main/java/io/ably/lib/object/ValueType.java +++ b/lib/src/main/java/io/ably/lib/object/ValueType.java @@ -23,6 +23,6 @@ public enum ValueType { LIVE_MAP, /** Corresponds to a {@code LiveCounter} object. Spec: RTTS2a8 */ LIVE_COUNTER, - /** Returned when path resolution fails or the resolved value has none of the known types; never produced by an {@code Instance} in normal operation. Spec: RTTS2a9 */ + /** Returned by {@code PathObject#getType()} only when a value is present but matches none of the known types. Never produced by an {@code Instance} in normal operation. Spec: RTTS2a9 */ UNKNOWN, } diff --git a/lib/src/main/java/io/ably/lib/object/path/PathObject.java b/lib/src/main/java/io/ably/lib/object/path/PathObject.java index 0e60bb378..a6d7aad3c 100644 --- a/lib/src/main/java/io/ably/lib/object/path/PathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/PathObject.java @@ -43,17 +43,22 @@ public interface PathObject { /** - * Returns the {@link ValueType} of the value resolved at this path currently. - * Use this instead of dedicated {@code isLiveMap}/{@code isLiveCounter}/etc. checks. + * Returns the {@link ValueType} of the value currently resolved at this path, or + * {@code null} when the path does not resolve to any value. Use this instead of + * dedicated {@code isLiveMap}/{@code isLiveCounter}/etc. checks. * - *

Returns {@link ValueType#UNKNOWN} when the path does not resolve or the - * resolved value falls into none of the known categories. + *

A {@code null} result means there is no value at this path - nothing is stored + * there (e.g. an absent or removed map entry). This is deliberately distinct from + * {@link ValueType#UNKNOWN}, which is returned only when a value is present + * but its type matches none of the known categories. In other words: {@code null} + * means "no value", {@code UNKNOWN} means "a value of an unrecognized type". * *

Spec: RTTS4b * - * @return the resolved value type at this path + * @return the resolved value type at this path, or {@code null} if the path does + * not resolve to a value */ - @NotNull ValueType getType(); + @Nullable ValueType getType(); /** * Returns a dot-delimited string representation of the stored path segments. diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt index 3535ef83f..1226ae195 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt @@ -22,6 +22,8 @@ import io.ably.lib.`object`.path.types.LiveCounterPathObject import io.ably.lib.`object`.path.types.LiveMapPathObject import io.ably.lib.`object`.path.types.NumberPathObject import io.ably.lib.`object`.path.types.StringPathObject +import io.ably.lib.`object`.value.ResolvedValue +import io.ably.lib.`object`.value.valueType /** * Default implementation of [PathObject], the untyped node in the path-addressed view of @@ -35,33 +37,34 @@ import io.ably.lib.`object`.path.types.StringPathObject */ internal open class DefaultPathObject( internal val channelObject: DefaultRealtimeObject, + internal val path: String ) : PathObject { - override fun path(): String = TODO("Not yet implemented") + override fun path(): String = path - override fun getType(): ValueType = TODO("Not yet implemented") + override fun getType(): ValueType? = resolveValueAtPath(path)?.valueType() override fun instance(): Instance? = TODO("Not yet implemented") override fun compactJson(): JsonElement? = TODO("Not yet implemented") - override fun exists(): Boolean = TODO("Not yet implemented") + override fun exists(): Boolean = resolveValueAtPath(path) != null - override fun asLiveMap(): LiveMapPathObject = DefaultLiveMapPathObject(channelObject) + override fun asLiveMap(): LiveMapPathObject = DefaultLiveMapPathObject(channelObject, path) - override fun asLiveCounter(): LiveCounterPathObject = DefaultLiveCounterPathObject(channelObject) + override fun asLiveCounter(): LiveCounterPathObject = DefaultLiveCounterPathObject(channelObject, path) - override fun asNumber(): NumberPathObject = DefaultNumberPathObject(channelObject) + override fun asNumber(): NumberPathObject = DefaultNumberPathObject(channelObject, path) - override fun asString(): StringPathObject = DefaultStringPathObject(channelObject) + override fun asString(): StringPathObject = DefaultStringPathObject(channelObject, path) - override fun asBoolean(): BooleanPathObject = DefaultBooleanPathObject(channelObject) + override fun asBoolean(): BooleanPathObject = DefaultBooleanPathObject(channelObject, path) - override fun asBinary(): BinaryPathObject = DefaultBinaryPathObject(channelObject) + override fun asBinary(): BinaryPathObject = DefaultBinaryPathObject(channelObject, path) - override fun asJsonObject(): JsonObjectPathObject = DefaultJsonObjectPathObject(channelObject) + override fun asJsonObject(): JsonObjectPathObject = DefaultJsonObjectPathObject(channelObject, path) - override fun asJsonArray(): JsonArrayPathObject = DefaultJsonArrayPathObject(channelObject) + override fun asJsonArray(): JsonArrayPathObject = DefaultJsonArrayPathObject(channelObject, path) override fun subscribe(listener: PathObjectListener): Subscription = subscribe(listener, null) @@ -71,4 +74,9 @@ internal open class DefaultPathObject( // TODO - remove PathObjectListener from list } } + + protected fun resolveValueAtPath(path: String): ResolvedValue? { + // TODO - resolve the path against the live objects graph and return the value at that position + return null + } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt index eacb2b8e5..7e5d4c258 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt @@ -11,7 +11,8 @@ import io.ably.lib.`object`.path.DefaultPathObject */ internal class DefaultBinaryPathObject( channelObject: DefaultRealtimeObject, -) : DefaultPathObject(channelObject), BinaryPathObject { + path: String, +) : DefaultPathObject(channelObject, path), BinaryPathObject { @Suppress("RedundantNullableReturnType") override fun value(): ByteArray? = TODO("Not yet implemented") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt index 8616e2610..d2a275749 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt @@ -11,7 +11,8 @@ import io.ably.lib.`object`.path.DefaultPathObject */ internal class DefaultBooleanPathObject( channelObject: DefaultRealtimeObject, -) : DefaultPathObject(channelObject), BooleanPathObject { + path: String, +) : DefaultPathObject(channelObject, path), BooleanPathObject { @Suppress("RedundantNullableReturnType") override fun value(): Boolean? = TODO("Not yet implemented") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt index d52eb0d41..9ba4b80ac 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt @@ -12,7 +12,8 @@ import io.ably.lib.`object`.path.DefaultPathObject */ internal class DefaultJsonArrayPathObject( channelObject: DefaultRealtimeObject, -) : DefaultPathObject(channelObject), JsonArrayPathObject { + path: String, +) : DefaultPathObject(channelObject, path), JsonArrayPathObject { @Suppress("RedundantNullableReturnType") override fun value(): JsonArray? = TODO("Not yet implemented") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt index f47426109..fc718222a 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt @@ -12,7 +12,8 @@ import io.ably.lib.`object`.path.DefaultPathObject */ internal class DefaultJsonObjectPathObject( channelObject: DefaultRealtimeObject, -) : DefaultPathObject(channelObject), JsonObjectPathObject { + path: String, +) : DefaultPathObject(channelObject, path), JsonObjectPathObject { @Suppress("RedundantNullableReturnType") override fun value(): JsonObject? = TODO("Not yet implemented") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt index 2d6ec09ee..96c4d58e9 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt @@ -14,7 +14,8 @@ import java.util.concurrent.CompletableFuture */ internal class DefaultLiveCounterPathObject( channelObject: DefaultRealtimeObject, -) : DefaultPathObject(channelObject), LiveCounterPathObject { + path: String, +) : DefaultPathObject(channelObject, path), LiveCounterPathObject { @Suppress("RedundantNullableReturnType") override fun value(): Double? = TODO("Not yet implemented") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt index 91d1d1f75..c8a26fbeb 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt @@ -14,7 +14,8 @@ import java.util.concurrent.CompletableFuture */ internal class DefaultLiveMapPathObject( channelObject: DefaultRealtimeObject, -) : DefaultPathObject(channelObject), LiveMapPathObject { + path: String, +) : DefaultPathObject(channelObject, path), LiveMapPathObject { override fun get(key: String): PathObject = TODO("Not yet implemented") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt index dd3e6d40e..37ffcd4ca 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt @@ -11,7 +11,8 @@ import io.ably.lib.`object`.path.DefaultPathObject */ internal class DefaultNumberPathObject( channelObject: DefaultRealtimeObject, -) : DefaultPathObject(channelObject), NumberPathObject { + path: String, +) : DefaultPathObject(channelObject, path), NumberPathObject { @Suppress("RedundantNullableReturnType") override fun value(): Number? = TODO("Not yet implemented") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt index 31671f83b..51850dff5 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt @@ -11,7 +11,8 @@ import io.ably.lib.`object`.path.DefaultPathObject */ internal class DefaultStringPathObject( channelObject: DefaultRealtimeObject, -) : DefaultPathObject(channelObject), StringPathObject { + path: String, +) : DefaultPathObject(channelObject, path), StringPathObject { @Suppress("RedundantNullableReturnType") override fun value(): String? = TODO("Not yet implemented") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/value/ResolvedValue.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/value/ResolvedValue.kt new file mode 100644 index 000000000..e74b44ff9 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/value/ResolvedValue.kt @@ -0,0 +1,39 @@ +package io.ably.lib.`object`.value + +import io.ably.lib.`object`.ValueType +import io.ably.lib.`object`.message.WireObjectData + +/** + * The result of resolving a path segment / map entry against the objects + * graph: either a node view of a live object, or a primitive leaf carried as + * wire ObjectData. + */ +internal sealed interface ResolvedValue { + data class MapRef(val map: LiveMap) : ResolvedValue // TODO: LiveMap will be replaced by InternalLiveMap + data class CounterRef(val counter: LiveCounter) : ResolvedValue // TODO: LiveCounter will be replaced by InternalLiveCounter + data class Leaf(val data: WireObjectData) : ResolvedValue +} + +/** + * Maps a resolved value to the public ValueType enum. + * + * Only ever invoked on a value that resolved to something - absence at a path is + * represented by a `null` [ResolvedValue] and surfaced as a `null` type by the + * caller, never as [ValueType.UNKNOWN]. UNKNOWN is reserved for a value that is + * present but matches none of the known categories. + * + * Spec: RTTS2a, RTTS4b3 + */ +internal fun ResolvedValue.valueType(): ValueType = when (this) { + is ResolvedValue.MapRef -> ValueType.LIVE_MAP + is ResolvedValue.CounterRef -> ValueType.LIVE_COUNTER + is ResolvedValue.Leaf -> when { + data.string != null -> ValueType.STRING + data.number != null -> ValueType.NUMBER + data.boolean != null -> ValueType.BOOLEAN + data.bytes != null -> ValueType.BINARY + data.json?.isJsonObject == true -> ValueType.JSON_OBJECT + data.json?.isJsonArray == true -> ValueType.JSON_ARRAY + else -> ValueType.UNKNOWN + } +} From 0a9ea026ff19d49e8dc6aa531a6257fe2af680dd Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 17 Jun 2026 18:01:32 +0530 Subject: [PATCH 13/40] Implemented resolveValueAtPath guards for terminal operations similar to ably-js --- .../ably/lib/object/path/DefaultPathObject.kt | 15 +++++- .../path/types/DefaultBinaryPathObject.kt | 8 +++- .../path/types/DefaultBooleanPathObject.kt | 8 +++- .../path/types/DefaultJsonArrayPathObject.kt | 8 +++- .../path/types/DefaultJsonObjectPathObject.kt | 8 +++- .../types/DefaultLiveCounterPathObject.kt | 46 ++++++++++++++++--- .../path/types/DefaultLiveMapPathObject.kt | 46 ++++++++++++++++--- .../path/types/DefaultNumberPathObject.kt | 8 +++- .../path/types/DefaultStringPathObject.kt | 8 +++- 9 files changed, 128 insertions(+), 27 deletions(-) diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt index 1226ae195..ab6432fd4 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt @@ -44,9 +44,20 @@ internal open class DefaultPathObject( override fun getType(): ValueType? = resolveValueAtPath(path)?.valueType() - override fun instance(): Instance? = TODO("Not yet implemented") + override fun instance(): Instance? { + val resolvedValue = resolveValueAtPath(path) ?: return null // unresolved path -> no instance + return when (resolvedValue) { + is ResolvedValue.Leaf -> null // primitives have no Instance; only live objects do + // TODO - wrap the resolved live object (LiveMap/LiveCounter) in an Instance + is ResolvedValue.MapRef, is ResolvedValue.CounterRef -> TODO("Not yet implemented") + } + } - override fun compactJson(): JsonElement? = TODO("Not yet implemented") + override fun compactJson(): JsonElement? { + resolveValueAtPath(path) ?: return null // unresolved path -> null + // TODO - build the compacted JSON snapshot (LiveMap -> JsonObject, LiveCounter -> number, leaf -> JSON value) + TODO("Not yet implemented") + } override fun exists(): Boolean = resolveValueAtPath(path) != null diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt index 7e5d4c258..c8d16c4db 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt @@ -2,6 +2,7 @@ package io.ably.lib.`object`.path.types import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.path.DefaultPathObject +import io.ably.lib.`object`.value.ResolvedValue /** * Default implementation of [BinaryPathObject], a terminal primitive view that only adds a @@ -14,6 +15,9 @@ internal class DefaultBinaryPathObject( path: String, ) : DefaultPathObject(channelObject, path), BinaryPathObject { - @Suppress("RedundantNullableReturnType") - override fun value(): ByteArray? = TODO("Not yet implemented") + override fun value(): ByteArray? { + if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value + // TODO - extract the primitive value from the resolved leaf, narrowed to ByteArray (base64-decoded) + TODO("Not yet implemented") + } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt index d2a275749..79a889285 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt @@ -2,6 +2,7 @@ package io.ably.lib.`object`.path.types import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.path.DefaultPathObject +import io.ably.lib.`object`.value.ResolvedValue /** * Default implementation of [BooleanPathObject], a terminal primitive view that only adds a @@ -14,6 +15,9 @@ internal class DefaultBooleanPathObject( path: String, ) : DefaultPathObject(channelObject, path), BooleanPathObject { - @Suppress("RedundantNullableReturnType") - override fun value(): Boolean? = TODO("Not yet implemented") + override fun value(): Boolean? { + if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value + // TODO - extract the primitive value from the resolved leaf, narrowed to Boolean + TODO("Not yet implemented") + } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt index 9ba4b80ac..2095103b8 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt @@ -3,6 +3,7 @@ package io.ably.lib.`object`.path.types import com.google.gson.JsonArray import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.path.DefaultPathObject +import io.ably.lib.`object`.value.ResolvedValue /** * Default implementation of [JsonArrayPathObject], a terminal primitive view that only adds @@ -15,6 +16,9 @@ internal class DefaultJsonArrayPathObject( path: String, ) : DefaultPathObject(channelObject, path), JsonArrayPathObject { - @Suppress("RedundantNullableReturnType") - override fun value(): JsonArray? = TODO("Not yet implemented") + override fun value(): JsonArray? { + if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value + // TODO - extract the primitive value from the resolved leaf, narrowed to JsonArray + TODO("Not yet implemented") + } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt index fc718222a..5782b0b6e 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt @@ -3,6 +3,7 @@ package io.ably.lib.`object`.path.types import com.google.gson.JsonObject import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.path.DefaultPathObject +import io.ably.lib.`object`.value.ResolvedValue /** * Default implementation of [JsonObjectPathObject], a terminal primitive view that only adds @@ -15,6 +16,9 @@ internal class DefaultJsonObjectPathObject( path: String, ) : DefaultPathObject(channelObject, path), JsonObjectPathObject { - @Suppress("RedundantNullableReturnType") - override fun value(): JsonObject? = TODO("Not yet implemented") + override fun value(): JsonObject? { + if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value + // TODO - extract the primitive value from the resolved leaf, narrowed to JsonObject + TODO("Not yet implemented") + } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt index 96c4d58e9..6dc21a5ed 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt @@ -2,6 +2,9 @@ package io.ably.lib.`object`.path.types import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.path.DefaultPathObject +import io.ably.lib.`object`.pathNotResolvedError +import io.ably.lib.`object`.typeMismatchError +import io.ably.lib.`object`.value.ResolvedValue import java.util.concurrent.CompletableFuture /** @@ -17,14 +20,45 @@ internal class DefaultLiveCounterPathObject( path: String, ) : DefaultPathObject(channelObject, path), LiveCounterPathObject { - @Suppress("RedundantNullableReturnType") - override fun value(): Double? = TODO("Not yet implemented") + override fun value(): Double? { + if (resolveValueAtPath(path) !is ResolvedValue.CounterRef) return null // not a LiveCounter (or unresolved) -> null + // TODO - return the resolved counter's value + TODO("Not yet implemented") + } - override fun increment(): CompletableFuture = TODO("Not yet implemented") + override fun increment(): CompletableFuture { + val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) + if (resolvedValue !is ResolvedValue.CounterRef) { + throw typeMismatchError("Cannot increment a non-LiveCounter object at path: \"$path\"") + } + // TODO - delegate the COUNTER_INC (amount 1) to the resolved LiveCounter + TODO("Not yet implemented") + } - override fun increment(amount: Number): CompletableFuture = TODO("Not yet implemented") + override fun increment(amount: Number): CompletableFuture { + val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) + if (resolvedValue !is ResolvedValue.CounterRef) { + throw typeMismatchError("Cannot increment a non-LiveCounter object at path: \"$path\"") + } + // TODO - delegate the COUNTER_INC to the resolved LiveCounter + TODO("Not yet implemented") + } - override fun decrement(): CompletableFuture = TODO("Not yet implemented") + override fun decrement(): CompletableFuture { + val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) + if (resolvedValue !is ResolvedValue.CounterRef) { + throw typeMismatchError("Cannot decrement a non-LiveCounter object at path: \"$path\"") + } + // TODO - delegate the COUNTER_INC (negated amount 1) to the resolved LiveCounter + TODO("Not yet implemented") + } - override fun decrement(amount: Number): CompletableFuture = TODO("Not yet implemented") + override fun decrement(amount: Number): CompletableFuture { + val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) + if (resolvedValue !is ResolvedValue.CounterRef) { + throw typeMismatchError("Cannot decrement a non-LiveCounter object at path: \"$path\"") + } + // TODO - delegate the COUNTER_INC (negated amount) to the resolved LiveCounter + TODO("Not yet implemented") + } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt index c8a26fbeb..a37df42c0 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt @@ -3,7 +3,10 @@ package io.ably.lib.`object`.path.types import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.path.DefaultPathObject import io.ably.lib.`object`.path.PathObject +import io.ably.lib.`object`.pathNotResolvedError +import io.ably.lib.`object`.typeMismatchError import io.ably.lib.`object`.value.LiveMapValue +import io.ably.lib.`object`.value.ResolvedValue import java.util.concurrent.CompletableFuture /** @@ -21,16 +24,45 @@ internal class DefaultLiveMapPathObject( override fun at(path: String): PathObject = TODO("Not yet implemented") - override fun entries(): Iterable> = TODO("Not yet implemented") + override fun entries(): Iterable> { + if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return emptyList() // not a LiveMap (or unresolved) -> empty + // TODO - iterate the resolved map's entries, yielding (key, child PathObject) + TODO("Not yet implemented") + } - override fun keys(): Iterable = TODO("Not yet implemented") + override fun keys(): Iterable { + if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return emptyList() // not a LiveMap (or unresolved) -> empty + // TODO - return the resolved map's keys + TODO("Not yet implemented") + } - override fun values(): Iterable = TODO("Not yet implemented") + override fun values(): Iterable { + if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return emptyList() // not a LiveMap (or unresolved) -> empty + // TODO - return a child PathObject for each entry of the resolved map + TODO("Not yet implemented") + } - @Suppress("RedundantNullableReturnType") - override fun size(): Long? = TODO("Not yet implemented") + override fun size(): Long? { + if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return null // not a LiveMap (or unresolved) -> null + // TODO - return the resolved map's size + TODO("Not yet implemented") + } - override fun set(key: String, value: LiveMapValue): CompletableFuture = TODO("Not yet implemented") + override fun set(key: String, value: LiveMapValue): CompletableFuture { + val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) + if (resolvedValue !is ResolvedValue.MapRef) { + throw typeMismatchError("Cannot set a key on a non-LiveMap object at path: \"$path\"") + } + // TODO - delegate the MAP_SET to the resolved LiveMap + TODO("Not yet implemented") + } - override fun remove(key: String): CompletableFuture = TODO("Not yet implemented") + override fun remove(key: String): CompletableFuture { + val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) + if (resolvedValue !is ResolvedValue.MapRef) { + throw typeMismatchError("Cannot remove a key from a non-LiveMap object at path: \"$path\"") + } + // TODO - delegate the MAP_REMOVE to the resolved LiveMap + TODO("Not yet implemented") + } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt index 37ffcd4ca..9d5c34e28 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt @@ -2,6 +2,7 @@ package io.ably.lib.`object`.path.types import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.path.DefaultPathObject +import io.ably.lib.`object`.value.ResolvedValue /** * Default implementation of [NumberPathObject], a terminal primitive view that only adds a @@ -14,6 +15,9 @@ internal class DefaultNumberPathObject( path: String, ) : DefaultPathObject(channelObject, path), NumberPathObject { - @Suppress("RedundantNullableReturnType") - override fun value(): Number? = TODO("Not yet implemented") + override fun value(): Number? { + if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value + // TODO - extract the primitive value from the resolved leaf, narrowed to Number + TODO("Not yet implemented") + } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt index 51850dff5..5bf6b79f4 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt @@ -2,6 +2,7 @@ package io.ably.lib.`object`.path.types import io.ably.lib.`object`.DefaultRealtimeObject import io.ably.lib.`object`.path.DefaultPathObject +import io.ably.lib.`object`.value.ResolvedValue /** * Default implementation of [StringPathObject], a terminal primitive view that only adds a @@ -14,6 +15,9 @@ internal class DefaultStringPathObject( path: String, ) : DefaultPathObject(channelObject, path), StringPathObject { - @Suppress("RedundantNullableReturnType") - override fun value(): String? = TODO("Not yet implemented") + override fun value(): String? { + if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value + // TODO - extract the primitive value from the resolved leaf, narrowed to String + TODO("Not yet implemented") + } } From 3c25c13dc4259248e89c7205f97d6cb8426bf1fe Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 17 Jun 2026 21:04:13 +0530 Subject: [PATCH 14/40] Added liveobjects read/write operation validation --- .../ably/lib/object/DefaultRealtimeObject.kt | 6 ++ .../main/kotlin/io/ably/lib/object/Helpers.kt | 68 +++++++++++++++++++ .../instance/types/DefaultBinaryInstance.kt | 10 ++- .../instance/types/DefaultBooleanInstance.kt | 10 ++- .../types/DefaultJsonArrayInstance.kt | 10 ++- .../types/DefaultJsonObjectInstance.kt | 10 ++- .../types/DefaultLiveCounterInstance.kt | 31 +++++++-- .../instance/types/DefaultLiveMapInstance.kt | 41 ++++++++--- .../instance/types/DefaultNumberInstance.kt | 10 ++- .../instance/types/DefaultStringInstance.kt | 10 ++- .../ably/lib/object/path/DefaultPathObject.kt | 13 +++- .../path/types/DefaultBinaryPathObject.kt | 1 + .../path/types/DefaultBooleanPathObject.kt | 1 + .../path/types/DefaultJsonArrayPathObject.kt | 1 + .../path/types/DefaultJsonObjectPathObject.kt | 1 + .../types/DefaultLiveCounterPathObject.kt | 5 ++ .../path/types/DefaultLiveMapPathObject.kt | 6 ++ .../path/types/DefaultNumberPathObject.kt | 1 + .../path/types/DefaultStringPathObject.kt | 1 + 19 files changed, 208 insertions(+), 28 deletions(-) diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObject.kt index 11807cbaa..36352b2c3 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObject.kt @@ -32,4 +32,10 @@ internal class DefaultRealtimeObject( override fun off(listener: ObjectStateChange.Listener): Unit = TODO("Not yet implemented") override fun offAll(): Unit = TODO("Not yet implemented") + + /** Validates the channel is configured for access (read/subscribe) operations. Spec: RTLO4b1 */ + internal fun throwIfInvalidAccessApiConfiguration() = adapter.throwIfInvalidAccessApiConfiguration(channelName) + + /** Validates the channel is configured for write (mutation) operations. Spec: RTLO4b2 */ + internal fun throwIfInvalidWriteApiConfiguration() = adapter.throwIfInvalidWriteApiConfiguration(channelName) } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt index 3bc7df1fd..249f43d91 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt @@ -1,5 +1,8 @@ package io.ably.lib.`object` +import io.ably.lib.`object`.adapter.AblyClientAdapter +import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.ChannelMode import java.util.concurrent.atomic.AtomicBoolean /** @@ -18,3 +21,68 @@ internal fun onceSubscription(onUnsubscribe: () -> Unit): Subscription { } } } + +/** + * Validates that the channel is configured for the access (read/subscribe) API: it must be + * attachable (not detached/failed) and have the `object_subscribe` mode. Copied from the + * legacy `io.ably.lib.objects` helpers so this package has no dependency on that package. + * + * Spec: RTLO4b1 + */ +internal fun AblyClientAdapter.throwIfInvalidAccessApiConfiguration(channelName: String) { + throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed)) + throwIfMissingChannelMode(channelName, ChannelMode.object_subscribe) +} + +/** + * Validates that the channel is configured for the write (mutation) API: message echo must be + * enabled, the channel must be usable (not detached/failed/suspended) and have the + * `object_publish` mode. + * + * Spec: RTLO4b2 + */ +internal fun AblyClientAdapter.throwIfInvalidWriteApiConfiguration(channelName: String) { + throwIfEchoMessagesDisabled() + throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed, ChannelState.suspended)) + throwIfMissingChannelMode(channelName, ChannelMode.object_publish) +} + +/** + * Resolves the effective channel modes: the attached `modes` if present, otherwise the + * user-provided channel options as a best effort. + * + * Spec: RTO2a, RTO2b + */ +private fun AblyClientAdapter.getChannelModes(channelName: String): Array? { + val channel = getChannel(channelName) + channel.modes?.let { modes -> if (modes.isNotEmpty()) return modes } // RTO2a + channel.options?.let { options -> if (options.hasModes()) return options.modes } // RTO2b + return null +} + +// Spec: RTO2a2, RTO2b2 +private fun AblyClientAdapter.throwIfMissingChannelMode(channelName: String, channelMode: ChannelMode) { + val channelModes = getChannelModes(channelName) + if (channelModes == null || !channelModes.contains(channelMode)) { + throw objectException( + "\"${channelMode.name}\" channel mode must be set for this operation", + ObjectErrorCode.ChannelModeRequired, + ) + } +} + +private fun AblyClientAdapter.throwIfInChannelState(channelName: String, channelStates: Array) { + val currentState = getChannel(channelName).state + if (currentState == null || channelStates.contains(currentState)) { + throw objectException("Channel is in invalid state: $currentState", ObjectErrorCode.ChannelStateError) + } +} + +private fun AblyClientAdapter.throwIfEchoMessagesDisabled() { + if (!clientOptions.echoMessages) { + throw objectException( + "\"echoMessages\" client option must be enabled for this operation", + ObjectErrorCode.BadRequest, + ) + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBinaryInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBinaryInstance.kt index bd4219a81..26a470a40 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBinaryInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBinaryInstance.kt @@ -17,9 +17,15 @@ internal class DefaultBinaryInstance( override fun getType(): ValueType = ValueType.BINARY - override fun compactJson(): JsonPrimitive = TODO("Not yet implemented") + override fun compactJson(): JsonPrimitive { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } override fun asBinary(): BinaryInstance = this - override fun value(): ByteArray = TODO("Not yet implemented") + override fun value(): ByteArray { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBooleanInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBooleanInstance.kt index 26bc2de67..3221ce1f4 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBooleanInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBooleanInstance.kt @@ -17,9 +17,15 @@ internal class DefaultBooleanInstance( override fun getType(): ValueType = ValueType.BOOLEAN - override fun compactJson(): JsonPrimitive = TODO("Not yet implemented") + override fun compactJson(): JsonPrimitive { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } override fun asBoolean(): BooleanInstance = this - override fun value(): Boolean = TODO("Not yet implemented") + override fun value(): Boolean { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonArrayInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonArrayInstance.kt index 47fe65e8e..4e3ba7701 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonArrayInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonArrayInstance.kt @@ -17,9 +17,15 @@ internal class DefaultJsonArrayInstance( override fun getType(): ValueType = ValueType.JSON_ARRAY - override fun compactJson(): JsonArray = TODO("Not yet implemented") + override fun compactJson(): JsonArray { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } override fun asJsonArray(): JsonArrayInstance = this - override fun value(): JsonArray = TODO("Not yet implemented") + override fun value(): JsonArray { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonObjectInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonObjectInstance.kt index 555e8736b..02dc7c15c 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonObjectInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonObjectInstance.kt @@ -17,9 +17,15 @@ internal class DefaultJsonObjectInstance( override fun getType(): ValueType = ValueType.JSON_OBJECT - override fun compactJson(): JsonObject = TODO("Not yet implemented") + override fun compactJson(): JsonObject { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } override fun asJsonObject(): JsonObjectInstance = this - override fun value(): JsonObject = TODO("Not yet implemented") + override fun value(): JsonObject { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveCounterInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveCounterInstance.kt index 50d647fcd..c78db653f 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveCounterInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveCounterInstance.kt @@ -21,23 +21,42 @@ internal class DefaultLiveCounterInstance( override fun getType(): ValueType = ValueType.LIVE_COUNTER - override fun compactJson(): JsonPrimitive = TODO("Not yet implemented") + override fun compactJson(): JsonPrimitive { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } override fun asLiveCounter(): LiveCounterInstance = this override fun getId(): String = TODO("Not yet implemented") - override fun value(): Double = TODO("Not yet implemented") + override fun value(): Double { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } - override fun increment(): CompletableFuture = TODO("Not yet implemented") + override fun increment(): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + TODO("Not yet implemented") + } - override fun increment(amount: Number): CompletableFuture = TODO("Not yet implemented") + override fun increment(amount: Number): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + TODO("Not yet implemented") + } - override fun decrement(): CompletableFuture = TODO("Not yet implemented") + override fun decrement(): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + TODO("Not yet implemented") + } - override fun decrement(amount: Number): CompletableFuture = TODO("Not yet implemented") + override fun decrement(amount: Number): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + TODO("Not yet implemented") + } override fun subscribe(listener: InstanceListener): Subscription { + channelObject.throwIfInvalidAccessApiConfiguration() // TODO - subscribe logic goes here return onceSubscription { // TODO - remove InstanceListener diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveMapInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveMapInstance.kt index 351bfd8c4..7142dc98a 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveMapInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveMapInstance.kt @@ -23,28 +23,53 @@ internal class DefaultLiveMapInstance( override fun getType(): ValueType = ValueType.LIVE_MAP - override fun compactJson(): JsonObject = TODO("Not yet implemented") + override fun compactJson(): JsonObject { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } override fun asLiveMap(): LiveMapInstance = this override fun getId(): String = TODO("Not yet implemented") @Suppress("RedundantNullableReturnType") - override fun get(key: String): Instance? = TODO("Not yet implemented") + override fun get(key: String): Instance? { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } - override fun entries(): Iterable> = TODO("Not yet implemented") + override fun entries(): Iterable> { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } - override fun keys(): Iterable = TODO("Not yet implemented") + override fun keys(): Iterable { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } - override fun values(): Iterable = TODO("Not yet implemented") + override fun values(): Iterable { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } - override fun size(): Long = TODO("Not yet implemented") + override fun size(): Long { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } - override fun set(key: String, value: LiveMapValue): CompletableFuture = TODO("Not yet implemented") + override fun set(key: String, value: LiveMapValue): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + TODO("Not yet implemented") + } - override fun remove(key: String): CompletableFuture = TODO("Not yet implemented") + override fun remove(key: String): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + TODO("Not yet implemented") + } override fun subscribe(listener: InstanceListener): Subscription { + channelObject.throwIfInvalidAccessApiConfiguration() // TODO - subscribe logic goes here return onceSubscription { // TODO - remove InstanceListener diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultNumberInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultNumberInstance.kt index fc57d3bf3..3e85ddade 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultNumberInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultNumberInstance.kt @@ -17,9 +17,15 @@ internal class DefaultNumberInstance( override fun getType(): ValueType = ValueType.NUMBER - override fun compactJson(): JsonPrimitive = TODO("Not yet implemented") + override fun compactJson(): JsonPrimitive { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } override fun asNumber(): NumberInstance = this - override fun value(): Number = TODO("Not yet implemented") + override fun value(): Number { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultStringInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultStringInstance.kt index 36194090c..74465782c 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultStringInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultStringInstance.kt @@ -17,9 +17,15 @@ internal class DefaultStringInstance( override fun getType(): ValueType = ValueType.STRING - override fun compactJson(): JsonPrimitive = TODO("Not yet implemented") + override fun compactJson(): JsonPrimitive { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } override fun asString(): StringInstance = this - override fun value(): String = TODO("Not yet implemented") + override fun value(): String { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt index ab6432fd4..5b6dced0c 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt @@ -42,9 +42,13 @@ internal open class DefaultPathObject( override fun path(): String = path - override fun getType(): ValueType? = resolveValueAtPath(path)?.valueType() + override fun getType(): ValueType? { + channelObject.throwIfInvalidAccessApiConfiguration() + return resolveValueAtPath(path)?.valueType() + } override fun instance(): Instance? { + channelObject.throwIfInvalidAccessApiConfiguration() val resolvedValue = resolveValueAtPath(path) ?: return null // unresolved path -> no instance return when (resolvedValue) { is ResolvedValue.Leaf -> null // primitives have no Instance; only live objects do @@ -54,12 +58,16 @@ internal open class DefaultPathObject( } override fun compactJson(): JsonElement? { + channelObject.throwIfInvalidAccessApiConfiguration() resolveValueAtPath(path) ?: return null // unresolved path -> null // TODO - build the compacted JSON snapshot (LiveMap -> JsonObject, LiveCounter -> number, leaf -> JSON value) TODO("Not yet implemented") } - override fun exists(): Boolean = resolveValueAtPath(path) != null + override fun exists(): Boolean { + channelObject.throwIfInvalidAccessApiConfiguration() + return resolveValueAtPath(path) != null + } override fun asLiveMap(): LiveMapPathObject = DefaultLiveMapPathObject(channelObject, path) @@ -80,6 +88,7 @@ internal open class DefaultPathObject( override fun subscribe(listener: PathObjectListener): Subscription = subscribe(listener, null) override fun subscribe(listener: PathObjectListener, options: PathObjectSubscriptionOptions?): Subscription { + channelObject.throwIfInvalidAccessApiConfiguration() // TODO - subscribe logic goes here return onceSubscription { // TODO - remove PathObjectListener from list diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt index c8d16c4db..d2bceab4f 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt @@ -16,6 +16,7 @@ internal class DefaultBinaryPathObject( ) : DefaultPathObject(channelObject, path), BinaryPathObject { override fun value(): ByteArray? { + channelObject.throwIfInvalidAccessApiConfiguration() if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value // TODO - extract the primitive value from the resolved leaf, narrowed to ByteArray (base64-decoded) TODO("Not yet implemented") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt index 79a889285..bfff0b4ee 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt @@ -16,6 +16,7 @@ internal class DefaultBooleanPathObject( ) : DefaultPathObject(channelObject, path), BooleanPathObject { override fun value(): Boolean? { + channelObject.throwIfInvalidAccessApiConfiguration() if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value // TODO - extract the primitive value from the resolved leaf, narrowed to Boolean TODO("Not yet implemented") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt index 2095103b8..6b10cab32 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt @@ -17,6 +17,7 @@ internal class DefaultJsonArrayPathObject( ) : DefaultPathObject(channelObject, path), JsonArrayPathObject { override fun value(): JsonArray? { + channelObject.throwIfInvalidAccessApiConfiguration() if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value // TODO - extract the primitive value from the resolved leaf, narrowed to JsonArray TODO("Not yet implemented") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt index 5782b0b6e..e4003ecbf 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt @@ -17,6 +17,7 @@ internal class DefaultJsonObjectPathObject( ) : DefaultPathObject(channelObject, path), JsonObjectPathObject { override fun value(): JsonObject? { + channelObject.throwIfInvalidAccessApiConfiguration() if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value // TODO - extract the primitive value from the resolved leaf, narrowed to JsonObject TODO("Not yet implemented") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt index 6dc21a5ed..7b5bb756c 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt @@ -21,12 +21,14 @@ internal class DefaultLiveCounterPathObject( ) : DefaultPathObject(channelObject, path), LiveCounterPathObject { override fun value(): Double? { + channelObject.throwIfInvalidAccessApiConfiguration() if (resolveValueAtPath(path) !is ResolvedValue.CounterRef) return null // not a LiveCounter (or unresolved) -> null // TODO - return the resolved counter's value TODO("Not yet implemented") } override fun increment(): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) if (resolvedValue !is ResolvedValue.CounterRef) { throw typeMismatchError("Cannot increment a non-LiveCounter object at path: \"$path\"") @@ -36,6 +38,7 @@ internal class DefaultLiveCounterPathObject( } override fun increment(amount: Number): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) if (resolvedValue !is ResolvedValue.CounterRef) { throw typeMismatchError("Cannot increment a non-LiveCounter object at path: \"$path\"") @@ -45,6 +48,7 @@ internal class DefaultLiveCounterPathObject( } override fun decrement(): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) if (resolvedValue !is ResolvedValue.CounterRef) { throw typeMismatchError("Cannot decrement a non-LiveCounter object at path: \"$path\"") @@ -54,6 +58,7 @@ internal class DefaultLiveCounterPathObject( } override fun decrement(amount: Number): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) if (resolvedValue !is ResolvedValue.CounterRef) { throw typeMismatchError("Cannot decrement a non-LiveCounter object at path: \"$path\"") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt index a37df42c0..6e1cd050e 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt @@ -25,30 +25,35 @@ internal class DefaultLiveMapPathObject( override fun at(path: String): PathObject = TODO("Not yet implemented") override fun entries(): Iterable> { + channelObject.throwIfInvalidAccessApiConfiguration() if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return emptyList() // not a LiveMap (or unresolved) -> empty // TODO - iterate the resolved map's entries, yielding (key, child PathObject) TODO("Not yet implemented") } override fun keys(): Iterable { + channelObject.throwIfInvalidAccessApiConfiguration() if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return emptyList() // not a LiveMap (or unresolved) -> empty // TODO - return the resolved map's keys TODO("Not yet implemented") } override fun values(): Iterable { + channelObject.throwIfInvalidAccessApiConfiguration() if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return emptyList() // not a LiveMap (or unresolved) -> empty // TODO - return a child PathObject for each entry of the resolved map TODO("Not yet implemented") } override fun size(): Long? { + channelObject.throwIfInvalidAccessApiConfiguration() if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return null // not a LiveMap (or unresolved) -> null // TODO - return the resolved map's size TODO("Not yet implemented") } override fun set(key: String, value: LiveMapValue): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) if (resolvedValue !is ResolvedValue.MapRef) { throw typeMismatchError("Cannot set a key on a non-LiveMap object at path: \"$path\"") @@ -58,6 +63,7 @@ internal class DefaultLiveMapPathObject( } override fun remove(key: String): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) if (resolvedValue !is ResolvedValue.MapRef) { throw typeMismatchError("Cannot remove a key from a non-LiveMap object at path: \"$path\"") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt index 9d5c34e28..0cf1179d3 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt @@ -16,6 +16,7 @@ internal class DefaultNumberPathObject( ) : DefaultPathObject(channelObject, path), NumberPathObject { override fun value(): Number? { + channelObject.throwIfInvalidAccessApiConfiguration() if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value // TODO - extract the primitive value from the resolved leaf, narrowed to Number TODO("Not yet implemented") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt index 5bf6b79f4..312b0a9cb 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt @@ -16,6 +16,7 @@ internal class DefaultStringPathObject( ) : DefaultPathObject(channelObject, path), StringPathObject { override fun value(): String? { + channelObject.throwIfInvalidAccessApiConfiguration() if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value // TODO - extract the primitive value from the resolved leaf, narrowed to String TODO("Not yet implemented") From 94b96a406c069e80064c6708fec4cea889d206b5 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 18 Jun 2026 16:34:24 +0530 Subject: [PATCH 15/40] Refactored javadoc for Instance interface, fixed other spec doc comments --- .../io/ably/lib/object/instance/Instance.java | 82 ++++++++++++------- .../ably/lib/object/message/ObjectData.java | 8 +- .../io/ably/lib/object/path/PathObject.java | 14 +++- .../ably/lib/object/DefaultRealtimeObject.kt | 4 +- .../main/kotlin/io/ably/lib/object/Helpers.kt | 4 +- 5 files changed, 69 insertions(+), 43 deletions(-) diff --git a/lib/src/main/java/io/ably/lib/object/instance/Instance.java b/lib/src/main/java/io/ably/lib/object/instance/Instance.java index e2c9cbed3..c29cadab4 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/Instance.java +++ b/lib/src/main/java/io/ably/lib/object/instance/Instance.java @@ -17,15 +17,19 @@ * {@code LiveCounter}) or primitive value. * *

Unlike {@code PathObject}, which re-resolves its path on every call, an - * {@code Instance} is identity-addressed: it is bound to a specific underlying value - * and dereferenced in O(1), regardless of where that value sits in the graph. Read - * operations validate the access API preconditions and fail with an - * {@code AblyException} if they are not satisfied. + * {@code Instance} is identity-addressed: it wraps an already-resolved value (typically + * obtained from a {@code PathObject}), so its type is fixed and known for the lifetime + * of the instance, and it is dereferenced in O(1) regardless of where that value sits + * in the graph. Read operations validate the access API preconditions and fail with an + * {@code AblyException} if those are not satisfied. * *

This base type exposes only the methods whose behaviour is independent of the * wrapped type; everything else - including {@code subscribe} (RTTS7b) - is * partitioned onto the sub-types. Use the {@code as*} helpers to obtain a sub-type - * view without type validation, or discriminate via {@link #getType()}. + * view, or discriminate via {@link #getType()}. Because the wrapped type is fixed and + * known, a mismatched {@code as*} cast fails fast with an {@link IllegalStateException} + * rather than returning a best-effort view (contrast {@code PathObject}, whose casts + * never throw). * *

Spec: RTINS1, RTTS7 * @@ -65,85 +69,101 @@ public interface Instance { @NotNull JsonElement compactJson(); /** - * Returns this instance wrapped as a {@link LiveMapInstance}. + * Returns this instance viewed as a {@link LiveMapInstance}. * - *

Best-effort cast; does not validate the underlying type. Read operations on - * the returned wrapper are always permitted; write/terminal operations will fail - * at call time if the wrapped value is not a {@code LiveMap}. + *

Because an {@code Instance} wraps an already-resolved value of a known, fixed + * type, this fails fast: it throws {@link IllegalStateException} if the wrapped value + * is not a {@code LiveMap}, rather than returning a best-effort view. Use + * {@link #getType()} to discriminate the type before casting. * - *

Spec: RTTS9a + *

Spec: RTTS9a / RTTS9d * * @return a {@link LiveMapInstance} view of this instance + * @throws IllegalStateException if the wrapped value is not a {@code LiveMap} */ @NotNull LiveMapInstance asLiveMap(); /** - * Returns this instance wrapped as a {@link LiveCounterInstance}. - * Best-effort cast; does not validate the underlying type. + * Returns this instance viewed as a {@link LiveCounterInstance}. + * Fails fast: throws {@link IllegalStateException} if the wrapped value is not a + * {@code LiveCounter}. * - *

Spec: RTTS9b + *

Spec: RTTS9b / RTTS9d * * @return a {@link LiveCounterInstance} view of this instance + * @throws IllegalStateException if the wrapped value is not a {@code LiveCounter} */ @NotNull LiveCounterInstance asLiveCounter(); /** - * Returns this instance wrapped as a {@link NumberInstance}. - * Best-effort cast; does not validate the underlying type. + * Returns this instance viewed as a {@link NumberInstance}. + * Fails fast: throws {@link IllegalStateException} if the wrapped value is not a + * {@code Number}. * - *

Spec: RTTS9c + *

Spec: RTTS9c / RTTS9d * * @return a {@link NumberInstance} view of this instance + * @throws IllegalStateException if the wrapped value is not a {@code Number} */ @NotNull NumberInstance asNumber(); /** - * Returns this instance wrapped as a {@link StringInstance}. - * Best-effort cast; does not validate the underlying type. + * Returns this instance viewed as a {@link StringInstance}. + * Fails fast: throws {@link IllegalStateException} if the wrapped value is not a + * {@code String}. * - *

Spec: RTTS9c + *

Spec: RTTS9c / RTTS9d * * @return a {@link StringInstance} view of this instance + * @throws IllegalStateException if the wrapped value is not a {@code String} */ @NotNull StringInstance asString(); /** - * Returns this instance wrapped as a {@link BooleanInstance}. - * Best-effort cast; does not validate the underlying type. + * Returns this instance viewed as a {@link BooleanInstance}. + * Fails fast: throws {@link IllegalStateException} if the wrapped value is not a + * {@code Boolean}. * - *

Spec: RTTS9c + *

Spec: RTTS9c / RTTS9d * * @return a {@link BooleanInstance} view of this instance + * @throws IllegalStateException if the wrapped value is not a {@code Boolean} */ @NotNull BooleanInstance asBoolean(); /** - * Returns this instance wrapped as a {@link BinaryInstance}. - * Best-effort cast; does not validate the underlying type. + * Returns this instance viewed as a {@link BinaryInstance}. + * Fails fast: throws {@link IllegalStateException} if the wrapped value is not a + * binary value. * - *

Spec: RTTS9c + *

Spec: RTTS9c / RTTS9d * * @return a {@link BinaryInstance} view of this instance + * @throws IllegalStateException if the wrapped value is not a binary value */ @NotNull BinaryInstance asBinary(); /** - * Returns this instance wrapped as a {@link JsonObjectInstance}. - * Best-effort cast; does not validate the underlying type. + * Returns this instance viewed as a {@link JsonObjectInstance}. + * Fails fast: throws {@link IllegalStateException} if the wrapped value is not a + * JSON object. * - *

Spec: RTTS9c + *

Spec: RTTS9c / RTTS9d * * @return a {@link JsonObjectInstance} view of this instance + * @throws IllegalStateException if the wrapped value is not a JSON object */ @NotNull JsonObjectInstance asJsonObject(); /** - * Returns this instance wrapped as a {@link JsonArrayInstance}. - * Best-effort cast; does not validate the underlying type. + * Returns this instance viewed as a {@link JsonArrayInstance}. + * Fails fast: throws {@link IllegalStateException} if the wrapped value is not a + * JSON array. * - *

Spec: RTTS9c + *

Spec: RTTS9c / RTTS9d * * @return a {@link JsonArrayInstance} view of this instance + * @throws IllegalStateException if the wrapped value is not a JSON array */ @NotNull JsonArrayInstance asJsonArray(); } diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectData.java b/lib/src/main/java/io/ably/lib/object/message/ObjectData.java index 7c2570634..25fb22f34 100644 --- a/lib/src/main/java/io/ably/lib/object/message/ObjectData.java +++ b/lib/src/main/java/io/ably/lib/object/message/ObjectData.java @@ -26,7 +26,7 @@ public interface ObjectData { /** * Returns the string value. * - *

Spec: OD2c + *

Spec: OD2f * * @return the string value, or {@code null} if not applicable */ @@ -35,7 +35,7 @@ public interface ObjectData { /** * Returns the numeric value. * - *

Spec: OD2c + *

Spec: OD2e * * @return the numeric value, or {@code null} if not applicable */ @@ -54,7 +54,7 @@ public interface ObjectData { * Returns the binary value. The returned array is the underlying message * payload and is not defensively copied; callers must treat it as read-only. * - *

Spec: OD2c + *

Spec: OD2d * * @return the binary value, or {@code null} if not applicable */ @@ -63,7 +63,7 @@ public interface ObjectData { /** * Returns the JSON object or array value. * - *

Spec: OD2c + *

Spec: OD2g * * @return the JSON value, or {@code null} if not applicable */ diff --git a/lib/src/main/java/io/ably/lib/object/path/PathObject.java b/lib/src/main/java/io/ably/lib/object/path/PathObject.java index a6d7aad3c..5e084e04d 100644 --- a/lib/src/main/java/io/ably/lib/object/path/PathObject.java +++ b/lib/src/main/java/io/ably/lib/object/path/PathObject.java @@ -21,11 +21,17 @@ * {@code LiveMap}. * *

A {@code PathObject} stores a path as an ordered list of string segments and - * resolves it against the local object graph each time a method is called. Resolution - * is best-effort: the value at a path may change between two calls (e.g. between + * resolves it against the local object graph each time a terminal method is called; + * the freshly resolved value is the sole basis for that call's result. Resolution is + * best-effort: the value at a path may change between two calls (e.g. between * {@link #exists()} and a subsequent write) as updates from other clients are applied. - * Operations that resolve the path validate the access/write API preconditions and - * fail with an {@code AblyException} if they are not satisfied. + * + *

When the path does not resolve, or resolves to a type the called method does not + * apply to, read operations degrade gracefully - returning {@code null} or an empty + * result - whereas write operations fail with an {@code AblyException} (code 92005 if + * the path does not resolve, 92007 on a type mismatch). All terminal operations + * additionally validate the access/write API preconditions and fail with an + * {@code AblyException} if those are not satisfied. * *

This base type exposes only the methods whose behaviour is independent of the * resolved type; map and counter reads/writes are partitioned onto the sub-types diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObject.kt index 36352b2c3..5e5bae61d 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObject.kt @@ -33,9 +33,9 @@ internal class DefaultRealtimeObject( override fun offAll(): Unit = TODO("Not yet implemented") - /** Validates the channel is configured for access (read/subscribe) operations. Spec: RTLO4b1 */ + /** Validates the channel is configured for access (read/subscribe) operations. Spec: RTO25 */ internal fun throwIfInvalidAccessApiConfiguration() = adapter.throwIfInvalidAccessApiConfiguration(channelName) - /** Validates the channel is configured for write (mutation) operations. Spec: RTLO4b2 */ + /** Validates the channel is configured for write (mutation) operations. Spec: RTO26 */ internal fun throwIfInvalidWriteApiConfiguration() = adapter.throwIfInvalidWriteApiConfiguration(channelName) } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt index 249f43d91..d86050ae0 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt @@ -27,7 +27,7 @@ internal fun onceSubscription(onUnsubscribe: () -> Unit): Subscription { * attachable (not detached/failed) and have the `object_subscribe` mode. Copied from the * legacy `io.ably.lib.objects` helpers so this package has no dependency on that package. * - * Spec: RTLO4b1 + * Spec: RTO25 */ internal fun AblyClientAdapter.throwIfInvalidAccessApiConfiguration(channelName: String) { throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed)) @@ -39,7 +39,7 @@ internal fun AblyClientAdapter.throwIfInvalidAccessApiConfiguration(channelName: * enabled, the channel must be usable (not detached/failed/suspended) and have the * `object_publish` mode. * - * Spec: RTLO4b2 + * Spec: RTO26 */ internal fun AblyClientAdapter.throwIfInvalidWriteApiConfiguration(channelName: String) { throwIfEchoMessagesDisabled() From c8a283d7b2b8b25c22596ff1dace5a55c630ff59 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 18 Jun 2026 23:18:02 +0530 Subject: [PATCH 16/40] Updated PathObject#value checks for primitives as per spec --- .../io/ably/lib/object/path/types/DefaultBinaryPathObject.kt | 5 +++-- .../ably/lib/object/path/types/DefaultBooleanPathObject.kt | 5 +++-- .../ably/lib/object/path/types/DefaultJsonArrayPathObject.kt | 5 +++-- .../lib/object/path/types/DefaultJsonObjectPathObject.kt | 5 +++-- .../io/ably/lib/object/path/types/DefaultNumberPathObject.kt | 5 +++-- .../io/ably/lib/object/path/types/DefaultStringPathObject.kt | 5 +++-- 6 files changed, 18 insertions(+), 12 deletions(-) diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt index d2bceab4f..d8e3e4980 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt @@ -1,8 +1,9 @@ package io.ably.lib.`object`.path.types import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.ValueType import io.ably.lib.`object`.path.DefaultPathObject -import io.ably.lib.`object`.value.ResolvedValue +import io.ably.lib.`object`.value.valueType /** * Default implementation of [BinaryPathObject], a terminal primitive view that only adds a @@ -17,7 +18,7 @@ internal class DefaultBinaryPathObject( override fun value(): ByteArray? { channelObject.throwIfInvalidAccessApiConfiguration() - if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value + if (resolveValueAtPath(path)?.valueType() != ValueType.BINARY) return null // not a Binary value at this path -> no value // TODO - extract the primitive value from the resolved leaf, narrowed to ByteArray (base64-decoded) TODO("Not yet implemented") } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt index bfff0b4ee..0ffdf3e7e 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt @@ -1,8 +1,9 @@ package io.ably.lib.`object`.path.types import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.ValueType import io.ably.lib.`object`.path.DefaultPathObject -import io.ably.lib.`object`.value.ResolvedValue +import io.ably.lib.`object`.value.valueType /** * Default implementation of [BooleanPathObject], a terminal primitive view that only adds a @@ -17,7 +18,7 @@ internal class DefaultBooleanPathObject( override fun value(): Boolean? { channelObject.throwIfInvalidAccessApiConfiguration() - if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value + if (resolveValueAtPath(path)?.valueType() != ValueType.BOOLEAN) return null // not a Boolean at this path -> no value // TODO - extract the primitive value from the resolved leaf, narrowed to Boolean TODO("Not yet implemented") } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt index 6b10cab32..6a05091dd 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt @@ -2,8 +2,9 @@ package io.ably.lib.`object`.path.types import com.google.gson.JsonArray import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.ValueType import io.ably.lib.`object`.path.DefaultPathObject -import io.ably.lib.`object`.value.ResolvedValue +import io.ably.lib.`object`.value.valueType /** * Default implementation of [JsonArrayPathObject], a terminal primitive view that only adds @@ -18,7 +19,7 @@ internal class DefaultJsonArrayPathObject( override fun value(): JsonArray? { channelObject.throwIfInvalidAccessApiConfiguration() - if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value + if (resolveValueAtPath(path)?.valueType() != ValueType.JSON_ARRAY) return null // not a JSON array at this path -> no value // TODO - extract the primitive value from the resolved leaf, narrowed to JsonArray TODO("Not yet implemented") } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt index e4003ecbf..197149718 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt @@ -2,8 +2,9 @@ package io.ably.lib.`object`.path.types import com.google.gson.JsonObject import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.ValueType import io.ably.lib.`object`.path.DefaultPathObject -import io.ably.lib.`object`.value.ResolvedValue +import io.ably.lib.`object`.value.valueType /** * Default implementation of [JsonObjectPathObject], a terminal primitive view that only adds @@ -18,7 +19,7 @@ internal class DefaultJsonObjectPathObject( override fun value(): JsonObject? { channelObject.throwIfInvalidAccessApiConfiguration() - if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value + if (resolveValueAtPath(path)?.valueType() != ValueType.JSON_OBJECT) return null // not a JSON object at this path -> no value // TODO - extract the primitive value from the resolved leaf, narrowed to JsonObject TODO("Not yet implemented") } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt index 0cf1179d3..7f1498dab 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt @@ -1,8 +1,9 @@ package io.ably.lib.`object`.path.types import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.ValueType import io.ably.lib.`object`.path.DefaultPathObject -import io.ably.lib.`object`.value.ResolvedValue +import io.ably.lib.`object`.value.valueType /** * Default implementation of [NumberPathObject], a terminal primitive view that only adds a @@ -17,7 +18,7 @@ internal class DefaultNumberPathObject( override fun value(): Number? { channelObject.throwIfInvalidAccessApiConfiguration() - if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value + if (resolveValueAtPath(path)?.valueType() != ValueType.NUMBER) return null // not a Number at this path -> no value // TODO - extract the primitive value from the resolved leaf, narrowed to Number TODO("Not yet implemented") } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt index 312b0a9cb..af9fa6255 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt @@ -1,8 +1,9 @@ package io.ably.lib.`object`.path.types import io.ably.lib.`object`.DefaultRealtimeObject +import io.ably.lib.`object`.ValueType import io.ably.lib.`object`.path.DefaultPathObject -import io.ably.lib.`object`.value.ResolvedValue +import io.ably.lib.`object`.value.valueType /** * Default implementation of [StringPathObject], a terminal primitive view that only adds a @@ -17,7 +18,7 @@ internal class DefaultStringPathObject( override fun value(): String? { channelObject.throwIfInvalidAccessApiConfiguration() - if (resolveValueAtPath(path) !is ResolvedValue.Leaf) return null // live object or unresolved -> no primitive value + if (resolveValueAtPath(path)?.valueType() != ValueType.STRING) return null // not a String at this path -> no value // TODO - extract the primitive value from the resolved leaf, narrowed to String TODO("Not yet implemented") } From 34c3bbeeffbc01d2a2090af89ea34a5de8eda54c Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 18 Jun 2026 23:33:37 +0530 Subject: [PATCH 17/40] Implemented Json and MsgPack serializers for path based liveobjects --- .../serialization/DefaultSerialization.kt | 44 + .../object/serialization/JsonSerialization.kt | 68 ++ .../serialization/MsgpackSerialization.kt | 908 ++++++++++++++++++ 3 files changed, 1020 insertions(+) create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/serialization/DefaultSerialization.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/serialization/JsonSerialization.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/serialization/MsgpackSerialization.kt diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/DefaultSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/DefaultSerialization.kt new file mode 100644 index 000000000..e8db5c956 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/DefaultSerialization.kt @@ -0,0 +1,44 @@ +package io.ably.lib.`object`.serialization + +import com.google.gson.* +import io.ably.lib.objects.* + +import io.ably.lib.objects.ObjectMessage +import org.msgpack.core.MessagePacker +import org.msgpack.core.MessageUnpacker + +/** + * Default implementation of {@link ObjectsSerializer} that handles serialization/deserialization + * of ObjectMessage arrays for both JSON and MessagePack formats using Jackson and Gson. + * Dynamically loaded by ObjectsHelper#getSerializer() to avoid hard dependencies. + */ +@Suppress("unused") // Used via reflection in ObjectsHelper +internal class DefaultObjectsSerializer : ObjectsSerializer { + + override fun readMsgpackArray(unpacker: MessageUnpacker): Array { + val objectMessagesCount = unpacker.unpackArrayHeader() + return Array(objectMessagesCount) { readObjectMessage(unpacker) } + } + + override fun writeMsgpackArray(objects: Array, packer: MessagePacker) { + val objectMessages = objects.map { it as ObjectMessage } + packer.packArrayHeader(objectMessages.size) + objectMessages.forEach { it.writeMsgpack(packer) } + } + + override fun readFromJsonArray(json: JsonArray): Array { + return json.map { element -> + if (element.isJsonObject) element.asJsonObject.toObjectMessage() + else throw JsonParseException("Expected JsonObject, but found: $element") + }.toTypedArray() + } + + override fun asJsonArray(objects: Array): JsonArray { + val objectMessages = objects.map { it as ObjectMessage } + val jsonArray = JsonArray() + for (objectMessage in objectMessages) { + jsonArray.add(objectMessage.toJsonObject()) + } + return jsonArray + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/JsonSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/JsonSerialization.kt new file mode 100644 index 000000000..07ef60cb7 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/JsonSerialization.kt @@ -0,0 +1,68 @@ +package io.ably.lib.`object`.serialization + +import com.google.gson.* +import io.ably.lib.objects.ObjectsMapSemantics +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.serialization.EnumCodeTypeAdapter +import java.lang.reflect.Type +import kotlin.enums.EnumEntries + +// Gson instance for JSON serialization/deserialization +internal val gson = GsonBuilder() + .registerTypeAdapter(ObjectOperationAction::class.java, EnumCodeTypeAdapter({ it.code }, ObjectOperationAction.entries)) + .registerTypeAdapter(ObjectsMapSemantics::class.java, EnumCodeTypeAdapter({ it.code }, ObjectsMapSemantics.entries)) + .create() + +internal fun ObjectMessage.toJsonObject(): JsonObject { + return gson.toJsonTree(this).asJsonObject +} + +internal fun JsonObject.toObjectMessage(): ObjectMessage { + return gson.fromJson(this, ObjectMessage::class.java) +} + +internal class EnumCodeTypeAdapter>( + private val getCode: (T) -> Int, + private val enumValues: EnumEntries +) : JsonSerializer, JsonDeserializer { + + override fun serialize(src: T, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { + return JsonPrimitive(getCode(src)) + } + + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): T { + val code = json.asInt + return enumValues.firstOrNull { getCode(it) == code } ?: enumValues.firstOrNull { getCode(it) == -1 } + ?: throw JsonParseException("Unknown enum code: $code and no Unknown fallback found") + } +} + +internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeserializer { + override fun serialize(src: ObjectData, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { + val obj = JsonObject() + src.objectId?.let { obj.addProperty("objectId", it) } + src.string?.let { obj.addProperty("string", it) } + src.number?.let { obj.addProperty("number", it) } + src.boolean?.let { obj.addProperty("boolean", it) } + src.bytes?.let { obj.addProperty("bytes", it) } + src.json?.let { obj.addProperty("json", it.toString()) } // Spec: OD4c5 + return obj + } + + override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): ObjectData { + val obj = if (json.isJsonObject) json.asJsonObject else throw JsonParseException("Expected JsonObject") + val objectId = if (obj.has("objectId")) obj.get("objectId").asString else null + val string = if (obj.has("string")) obj.get("string").asString else null + val number = if (obj.has("number")) obj.get("number").asDouble else null + val boolean = if (obj.has("boolean")) obj.get("boolean").asBoolean else null + val bytes = if (obj.has("bytes")) obj.get("bytes").asString else null + val json = if (obj.has("json")) JsonParser.parseString(obj.get("json").asString) else null + + if (objectId == null && string == null && number == null && boolean == null && bytes == null && json == null) { + throw JsonParseException("Since objectId is not present, at least one of the value fields must be present") + } + return ObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json) + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/MsgpackSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/MsgpackSerialization.kt new file mode 100644 index 000000000..52e3ef533 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/MsgpackSerialization.kt @@ -0,0 +1,908 @@ +package io.ably.lib.`object`.serialization + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import io.ably.lib.objects.* +import io.ably.lib.objects.CounterCreate +import io.ably.lib.objects.CounterCreateWithObjectId +import io.ably.lib.objects.CounterInc +import io.ably.lib.objects.MapCreate +import io.ably.lib.objects.MapCreateWithObjectId +import io.ably.lib.objects.MapRemove +import io.ably.lib.objects.MapSet +import io.ably.lib.objects.MapClear +import io.ably.lib.objects.ObjectDelete +import io.ably.lib.objects.ObjectsMapSemantics +import io.ably.lib.objects.ObjectsCounter +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectsMap +import io.ably.lib.objects.ObjectsMapEntry +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.ObjectState +import java.util.Base64 +import io.ably.lib.util.Serialisation +import org.msgpack.core.MessageFormat +import org.msgpack.core.MessagePacker +import org.msgpack.core.MessageUnpacker + +/** + * Write ObjectMessage to MessagePacker + */ +internal fun ObjectMessage.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (id != null) fieldCount++ + if (timestamp != null) fieldCount++ + if (clientId != null) fieldCount++ + if (connectionId != null) fieldCount++ + if (extras != null) fieldCount++ + if (operation != null) fieldCount++ + if (objectState != null) fieldCount++ + if (serial != null) fieldCount++ + if (serialTimestamp != null) fieldCount++ + if (siteCode != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + if (id != null) { + packer.packString("id") + packer.packString(id) + } + + if (timestamp != null) { + packer.packString("timestamp") + packer.packLong(timestamp) + } + + if (clientId != null) { + packer.packString("clientId") + packer.packString(clientId) + } + + if (connectionId != null) { + packer.packString("connectionId") + packer.packString(connectionId) + } + + if (extras != null) { + packer.packString("extras") + packer.writePayload(Serialisation.gsonToMsgpack(extras)) + } + + if (operation != null) { + packer.packString("operation") + operation.writeMsgpack(packer) + } + + if (objectState != null) { + packer.packString("object") + objectState.writeMsgpack(packer) + } + + if (serial != null) { + packer.packString("serial") + packer.packString(serial) + } + + if (serialTimestamp != null) { + packer.packString("serialTimestamp") + packer.packLong(serialTimestamp) + } + + if (siteCode != null) { + packer.packString("siteCode") + packer.packString(siteCode) + } +} + +/** + * Read an ObjectMessage from MessageUnpacker + */ +internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage { + if (unpacker.nextFormat == MessageFormat.NIL) { + unpacker.unpackNil() + return ObjectMessage() // default/empty message + } + + val fieldCount = unpacker.unpackMapHeader() + + var id: String? = null + var timestamp: Long? = null + var clientId: String? = null + var connectionId: String? = null + var extras: JsonObject? = null + var operation: ObjectOperation? = null + var objectState: ObjectState? = null + var serial: String? = null + var serialTimestamp: Long? = null + var siteCode: String? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "id" -> id = unpacker.unpackString() + "timestamp" -> timestamp = unpacker.unpackLong() + "clientId" -> clientId = unpacker.unpackString() + "connectionId" -> connectionId = unpacker.unpackString() + "extras" -> extras = Serialisation.msgpackToGson(unpacker.unpackValue()) as? JsonObject + "operation" -> operation = readObjectOperation(unpacker) + "object" -> objectState = readObjectState(unpacker) + "serial" -> serial = unpacker.unpackString() + "serialTimestamp" -> serialTimestamp = unpacker.unpackLong() + "siteCode" -> siteCode = unpacker.unpackString() + else -> unpacker.skipValue() + } + } + + return ObjectMessage( + id = id, + timestamp = timestamp, + clientId = clientId, + connectionId = connectionId, + extras = extras, + operation = operation, + objectState = objectState, + serial = serial, + serialTimestamp = serialTimestamp, + siteCode = siteCode + ) +} + +/** + * Write ObjectOperation to MessagePacker + */ +private fun ObjectOperation.writeMsgpack(packer: MessagePacker) { + var fieldCount = 1 // action is always required + require(objectId.isNotEmpty()) { "objectId must be non-empty per Objects protocol" } + fieldCount++ + + if (mapCreate != null) fieldCount++ + if (mapSet != null) fieldCount++ + if (mapRemove != null) fieldCount++ + if (counterCreate != null) fieldCount++ + if (counterInc != null) fieldCount++ + if (objectDelete != null) fieldCount++ + if (mapCreateWithObjectId != null) fieldCount++ + if (counterCreateWithObjectId != null) fieldCount++ + if (mapClear != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + packer.packString("action") + packer.packInt(action.code) + + // Always include objectId as per Objects protocol + packer.packString("objectId") + packer.packString(objectId) + + if (mapCreate != null) { + packer.packString("mapCreate") + mapCreate.writeMsgpack(packer) + } + + if (mapSet != null) { + packer.packString("mapSet") + mapSet.writeMsgpack(packer) + } + + if (mapRemove != null) { + packer.packString("mapRemove") + mapRemove.writeMsgpack(packer) + } + + if (counterCreate != null) { + packer.packString("counterCreate") + counterCreate.writeMsgpack(packer) + } + + if (counterInc != null) { + packer.packString("counterInc") + counterInc.writeMsgpack(packer) + } + + if (objectDelete != null) { + packer.packString("objectDelete") + packer.packMapHeader(0) // empty map + } + + if (mapCreateWithObjectId != null) { + packer.packString("mapCreateWithObjectId") + mapCreateWithObjectId.writeMsgpack(packer) + } + + if (counterCreateWithObjectId != null) { + packer.packString("counterCreateWithObjectId") + counterCreateWithObjectId.writeMsgpack(packer) + } + + if (mapClear != null) { + packer.packString("mapClear") + packer.packMapHeader(0) // empty map, no fields + } + +} + +/** + * Read ObjectOperation from MessageUnpacker + */ +private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation { + val fieldCount = unpacker.unpackMapHeader() + + var action: ObjectOperationAction? = null + var objectId: String = "" + var mapCreate: MapCreate? = null + var mapSet: MapSet? = null + var mapRemove: MapRemove? = null + var counterCreate: CounterCreate? = null + var counterInc: CounterInc? = null + var objectDelete: ObjectDelete? = null + var mapCreateWithObjectId: MapCreateWithObjectId? = null + var counterCreateWithObjectId: CounterCreateWithObjectId? = null + var mapClear: MapClear? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "action" -> { + val actionCode = unpacker.unpackInt() + action = ObjectOperationAction.entries.firstOrNull { it.code == actionCode } + ?: ObjectOperationAction.entries.firstOrNull { it.code == -1 } + ?: throw objectError("Unknown ObjectOperationAction code: $actionCode and no Unknown fallback found") + } + "objectId" -> objectId = unpacker.unpackString() + "mapCreate" -> mapCreate = readMapCreate(unpacker) + "mapSet" -> mapSet = readMapSet(unpacker) + "mapRemove" -> mapRemove = readMapRemove(unpacker) + "counterCreate" -> counterCreate = readCounterCreate(unpacker) + "counterInc" -> counterInc = readCounterInc(unpacker) + "objectDelete" -> { + unpacker.skipValue() // empty map, just consume it + objectDelete = ObjectDelete + } + "mapCreateWithObjectId" -> mapCreateWithObjectId = readMapCreateWithObjectId(unpacker) + "counterCreateWithObjectId" -> counterCreateWithObjectId = readCounterCreateWithObjectId(unpacker) + "mapClear" -> { + unpacker.skipValue() // empty map, consume it + mapClear = MapClear + } + else -> unpacker.skipValue() + } + } + + if (action == null) { + throw objectError("Missing required 'action' field in ObjectOperation") + } + + return ObjectOperation( + action = action, + objectId = objectId, + mapCreate = mapCreate, + mapSet = mapSet, + mapRemove = mapRemove, + counterCreate = counterCreate, + counterInc = counterInc, + objectDelete = objectDelete, + mapCreateWithObjectId = mapCreateWithObjectId, + counterCreateWithObjectId = counterCreateWithObjectId, + mapClear = mapClear, + ) +} + +/** + * Write ObjectState to MessagePacker + */ +private fun ObjectState.writeMsgpack(packer: MessagePacker) { + var fieldCount = 3 // objectId, siteTimeserials, and tombstone are required + + if (createOp != null) fieldCount++ + if (map != null) fieldCount++ + if (counter != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + packer.packString("objectId") + packer.packString(objectId) + + packer.packString("siteTimeserials") + packer.packMapHeader(siteTimeserials.size) + for ((key, value) in siteTimeserials) { + packer.packString(key) + packer.packString(value) + } + + packer.packString("tombstone") + packer.packBoolean(tombstone) + + if (createOp != null) { + packer.packString("createOp") + createOp.writeMsgpack(packer) + } + + if (map != null) { + packer.packString("map") + map.writeMsgpack(packer) + } + + if (counter != null) { + packer.packString("counter") + counter.writeMsgpack(packer) + } +} + +/** + * Read ObjectState from MessageUnpacker + */ +private fun readObjectState(unpacker: MessageUnpacker): ObjectState { + val fieldCount = unpacker.unpackMapHeader() + + var objectId = "" + var siteTimeserials = mapOf() + var tombstone = false + var createOp: ObjectOperation? = null + var map: ObjectsMap? = null + var counter: ObjectsCounter? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "objectId" -> objectId = unpacker.unpackString() + "siteTimeserials" -> { + val mapSize = unpacker.unpackMapHeader() + val tempMap = mutableMapOf() + for (j in 0 until mapSize) { + val key = unpacker.unpackString() + val value = unpacker.unpackString() + tempMap[key] = value + } + siteTimeserials = tempMap + } + "tombstone" -> tombstone = unpacker.unpackBoolean() + "createOp" -> createOp = readObjectOperation(unpacker) + "map" -> map = readObjectMap(unpacker) + "counter" -> counter = readObjectCounter(unpacker) + else -> unpacker.skipValue() + } + } + + return ObjectState( + objectId = objectId, + siteTimeserials = siteTimeserials, + tombstone = tombstone, + createOp = createOp, + map = map, + counter = counter + ) +} + +/** + * Write MapCreate to MessagePacker + */ +private fun MapCreate.writeMsgpack(packer: MessagePacker) { + packer.packMapHeader(2) + packer.packString("semantics") + packer.packInt(semantics.code) + packer.packString("entries") + packer.packMapHeader(entries.size) + for ((key, value) in entries) { + packer.packString(key) + value.writeMsgpack(packer) + } +} + +/** + * Read MapCreate from MessageUnpacker + */ +private fun readMapCreate(unpacker: MessageUnpacker): MapCreate { + val fieldCount = unpacker.unpackMapHeader() + var semantics: ObjectsMapSemantics = ObjectsMapSemantics.LWW + var entries: Map = emptyMap() + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } + when (fieldName) { + "semantics" -> { + val code = unpacker.unpackInt() + semantics = ObjectsMapSemantics.entries.firstOrNull { it.code == code } + ?: ObjectsMapSemantics.entries.firstOrNull { it.code == -1 } + ?: throw objectError("Unknown MapSemantics code: $code and no UNKNOWN fallback found") + } + "entries" -> { + val mapSize = unpacker.unpackMapHeader() + val tempMap = mutableMapOf() + for (j in 0 until mapSize) { + tempMap[unpacker.unpackString()] = readObjectMapEntry(unpacker) + } + entries = tempMap + } + else -> unpacker.skipValue() + } + } + return MapCreate(semantics = semantics, entries = entries) +} + +/** + * Write MapSet to MessagePacker + */ +private fun MapSet.writeMsgpack(packer: MessagePacker) { + packer.packMapHeader(2) + packer.packString("key") + packer.packString(key) + packer.packString("value") + value.writeMsgpack(packer) +} + +/** + * Read MapSet from MessageUnpacker + */ +private fun readMapSet(unpacker: MessageUnpacker): MapSet { + val fieldCount = unpacker.unpackMapHeader() + var key: String? = null + var value: ObjectData? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } + when (fieldName) { + "key" -> key = unpacker.unpackString() + "value" -> value = readObjectData(unpacker) + else -> unpacker.skipValue() + } + } + return MapSet( + key = key ?: throw objectError("Missing 'key' in MapSet payload"), + value = value ?: throw objectError("Missing 'value' in MapSet payload") + ) +} + +/** + * Write MapRemove to MessagePacker + */ +private fun MapRemove.writeMsgpack(packer: MessagePacker) { + packer.packMapHeader(1) + packer.packString("key") + packer.packString(key) +} + +/** + * Read MapRemove from MessageUnpacker + */ +private fun readMapRemove(unpacker: MessageUnpacker): MapRemove { + val fieldCount = unpacker.unpackMapHeader() + var key: String? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } + when (fieldName) { + "key" -> key = unpacker.unpackString() + else -> unpacker.skipValue() + } + } + return MapRemove(key = key ?: throw objectError("Missing 'key' in MapRemove payload")) +} + +/** + * Write CounterCreate to MessagePacker + */ +private fun CounterCreate.writeMsgpack(packer: MessagePacker) { + packer.packMapHeader(1) + packer.packString("count") + packer.packDouble(count) +} + +/** + * Read CounterCreate from MessageUnpacker + */ +private fun readCounterCreate(unpacker: MessageUnpacker): CounterCreate { + val fieldCount = unpacker.unpackMapHeader() + var count: Double? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } + when (fieldName) { + "count" -> count = unpacker.unpackDouble() + else -> unpacker.skipValue() + } + } + return CounterCreate(count = count ?: throw objectError("Missing 'count' in CounterCreate payload")) +} + +/** + * Write CounterInc to MessagePacker + */ +private fun CounterInc.writeMsgpack(packer: MessagePacker) { + packer.packMapHeader(1) + packer.packString("number") + packer.packDouble(number) +} + +/** + * Read CounterInc from MessageUnpacker + */ +private fun readCounterInc(unpacker: MessageUnpacker): CounterInc { + val fieldCount = unpacker.unpackMapHeader() + var number: Double? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } + when (fieldName) { + "number" -> number = unpacker.unpackDouble() + else -> unpacker.skipValue() + } + } + return CounterInc(number = number ?: throw objectError("Missing 'number' in CounterInc payload")) +} + +/** + * Write MapCreateWithObjectId to MessagePacker + */ +private fun MapCreateWithObjectId.writeMsgpack(packer: MessagePacker) { + packer.packMapHeader(2) + packer.packString("initialValue") + packer.packString(initialValue) + packer.packString("nonce") + packer.packString(nonce) +} + +/** + * Read MapCreateWithObjectId from MessageUnpacker + */ +private fun readMapCreateWithObjectId(unpacker: MessageUnpacker): MapCreateWithObjectId { + val fieldCount = unpacker.unpackMapHeader() + var initialValue: String? = null + var nonce: String? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } + when (fieldName) { + "initialValue" -> initialValue = unpacker.unpackString() + "nonce" -> nonce = unpacker.unpackString() + else -> unpacker.skipValue() + } + } + return MapCreateWithObjectId( + initialValue = initialValue ?: throw objectError("Missing 'initialValue' in MapCreateWithObjectId payload"), + nonce = nonce ?: throw objectError("Missing 'nonce' in MapCreateWithObjectId payload") + ) +} + +/** + * Write CounterCreateWithObjectId to MessagePacker + */ +private fun CounterCreateWithObjectId.writeMsgpack(packer: MessagePacker) { + packer.packMapHeader(2) + packer.packString("initialValue") + packer.packString(initialValue) + packer.packString("nonce") + packer.packString(nonce) +} + +/** + * Read CounterCreateWithObjectId from MessageUnpacker + */ +private fun readCounterCreateWithObjectId(unpacker: MessageUnpacker): CounterCreateWithObjectId { + val fieldCount = unpacker.unpackMapHeader() + var initialValue: String? = null + var nonce: String? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } + when (fieldName) { + "initialValue" -> initialValue = unpacker.unpackString() + "nonce" -> nonce = unpacker.unpackString() + else -> unpacker.skipValue() + } + } + return CounterCreateWithObjectId( + initialValue = initialValue ?: throw objectError("Missing 'initialValue' in CounterCreateWithObjectId payload"), + nonce = nonce ?: throw objectError("Missing 'nonce' in CounterCreateWithObjectId payload") + ) +} + +/** + * Write ObjectMap to MessagePacker + */ +private fun ObjectsMap.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (semantics != null) fieldCount++ + if (entries != null) fieldCount++ + if (clearTimeserial != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + if (semantics != null) { + packer.packString("semantics") + packer.packInt(semantics.code) + } + + if (entries != null) { + packer.packString("entries") + packer.packMapHeader(entries.size) + for ((key, value) in entries) { + packer.packString(key) + value.writeMsgpack(packer) + } + } + + if (clearTimeserial != null) { + packer.packString("clearTimeserial") + packer.packString(clearTimeserial) + } +} + +/** + * Read ObjectMap from MessageUnpacker + */ +private fun readObjectMap(unpacker: MessageUnpacker): ObjectsMap { + val fieldCount = unpacker.unpackMapHeader() + + var semantics: ObjectsMapSemantics? = null + var entries: Map? = null + var clearTimeserial: String? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "semantics" -> { + val semanticsCode = unpacker.unpackInt() + semantics = ObjectsMapSemantics.entries.firstOrNull { it.code == semanticsCode } + ?: ObjectsMapSemantics.entries.firstOrNull { it.code == -1 } + ?: throw objectError("Unknown MapSemantics code: $semanticsCode and no UNKNOWN fallback found") + } + "entries" -> { + val mapSize = unpacker.unpackMapHeader() + val tempMap = mutableMapOf() + for (j in 0 until mapSize) { + val key = unpacker.unpackString() + val value = readObjectMapEntry(unpacker) + tempMap[key] = value + } + entries = tempMap + } + "clearTimeserial" -> clearTimeserial = unpacker.unpackString() + else -> unpacker.skipValue() + } + } + + return ObjectsMap(semantics = semantics, entries = entries, clearTimeserial = clearTimeserial) +} + +/** + * Write ObjectCounter to MessagePacker + */ +private fun ObjectsCounter.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (count != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + if (count != null) { + packer.packString("count") + packer.packDouble(count) + } +} + +/** + * Read ObjectCounter from MessageUnpacker + */ +private fun readObjectCounter(unpacker: MessageUnpacker): ObjectsCounter { + val fieldCount = unpacker.unpackMapHeader() + + var count: Double? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "count" -> count = unpacker.unpackDouble() + else -> unpacker.skipValue() + } + } + + return ObjectsCounter(count = count) +} + +/** + * Write ObjectMapEntry to MessagePacker + */ +private fun ObjectsMapEntry.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (tombstone != null) fieldCount++ + if (timeserial != null) fieldCount++ + if (serialTimestamp != null) fieldCount++ + if (data != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + if (tombstone != null) { + packer.packString("tombstone") + packer.packBoolean(tombstone) + } + + if (timeserial != null) { + packer.packString("timeserial") + packer.packString(timeserial) + } + + if (serialTimestamp != null) { + packer.packString("serialTimestamp") + packer.packLong(serialTimestamp) + } + + if (data != null) { + packer.packString("data") + data.writeMsgpack(packer) + } +} + +/** + * Read ObjectMapEntry from MessageUnpacker + */ +private fun readObjectMapEntry(unpacker: MessageUnpacker): ObjectsMapEntry { + val fieldCount = unpacker.unpackMapHeader() + + var tombstone: Boolean? = null + var timeserial: String? = null + var serialTimestamp: Long? = null + var data: ObjectData? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "tombstone" -> tombstone = unpacker.unpackBoolean() + "timeserial" -> timeserial = unpacker.unpackString() + "serialTimestamp" -> serialTimestamp = unpacker.unpackLong() + "data" -> data = readObjectData(unpacker) + else -> unpacker.skipValue() + } + } + + return ObjectsMapEntry(tombstone = tombstone, timeserial = timeserial, serialTimestamp = serialTimestamp, data = data) +} + +/** + * Write ObjectData to MessagePacker + */ +private fun ObjectData.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (objectId != null) fieldCount++ + if (string != null) fieldCount++ + if (number != null) fieldCount++ + if (boolean != null) fieldCount++ + if (bytes != null) fieldCount++ + if (json != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + if (objectId != null) { + packer.packString("objectId") + packer.packString(objectId) + } + + if (string != null) { + packer.packString("string") + packer.packString(string) + } + + if (number != null) { + packer.packString("number") + packer.packDouble(number) + } + + if (boolean != null) { + packer.packString("boolean") + packer.packBoolean(boolean) + } + + if (bytes != null) { + val rawBytes = Base64.getDecoder().decode(bytes) + packer.packString("bytes") + packer.packBinaryHeader(rawBytes.size) + packer.writePayload(rawBytes) + } + + if (json != null) { + packer.packString("json") + packer.packString(json.toString()) + } +} + +/** + * Read ObjectData from MessageUnpacker + */ +private fun readObjectData(unpacker: MessageUnpacker): ObjectData { + val fieldCount = unpacker.unpackMapHeader() + var objectId: String? = null + var string: String? = null + var number: Double? = null + var boolean: Boolean? = null + var bytes: String? = null + var json: JsonElement? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "objectId" -> objectId = unpacker.unpackString() + "string" -> string = unpacker.unpackString() + "number" -> number = unpacker.unpackDouble() + "boolean" -> boolean = unpacker.unpackBoolean() + "bytes" -> { + val size = unpacker.unpackBinaryHeader() + val rawBytes = ByteArray(size) + unpacker.readPayload(rawBytes) + bytes = Base64.getEncoder().encodeToString(rawBytes) + } + "json" -> json = JsonParser.parseString(unpacker.unpackString()) + else -> unpacker.skipValue() + } + } + + return ObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json) +} From 5e452c29f664699338de01fc68475474d570fcec Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 19 Jun 2026 17:42:25 +0530 Subject: [PATCH 18/40] - Declared ObjectSerializer interface for json/msgpack encoding/decoding - Implemented JsonSerializer annotation for better json handling --- .../serialization/ObjectJsonSerializer.java | 33 +++++++ .../serialization/ObjectSerializer.java | 98 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 lib/src/main/java/io/ably/lib/object/serialization/ObjectJsonSerializer.java create mode 100644 lib/src/main/java/io/ably/lib/object/serialization/ObjectSerializer.java diff --git a/lib/src/main/java/io/ably/lib/object/serialization/ObjectJsonSerializer.java b/lib/src/main/java/io/ably/lib/object/serialization/ObjectJsonSerializer.java new file mode 100644 index 000000000..08e5d6b71 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/serialization/ObjectJsonSerializer.java @@ -0,0 +1,33 @@ +package io.ably.lib.object.serialization; + +import com.google.gson.*; +import io.ably.lib.util.Log; + +import java.lang.reflect.Type; + +public class ObjectJsonSerializer implements JsonSerializer, JsonDeserializer { + private static final String TAG = ObjectJsonSerializer.class.getName(); + + @Override + public Object[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + ObjectSerializer serializer = ObjectSerializer.tryGet(); + if (serializer == null) { + Log.w(TAG, "Skipping 'state' field json deserialization because ObjectsSerializer not found."); + return null; + } + if (!json.isJsonArray()) { + throw new JsonParseException("Expected a JSON array for 'state' field, but got: " + json); + } + return serializer.readFromJsonArray(json.getAsJsonArray()); + } + + @Override + public JsonElement serialize(Object[] src, Type typeOfSrc, JsonSerializationContext context) { + ObjectSerializer serializer = ObjectSerializer.tryGet(); + if (serializer == null) { + Log.w(TAG, "Skipping 'state' field json serialization because ObjectsSerializer not found."); + return JsonNull.INSTANCE; + } + return serializer.asJsonArray(src); + } +} diff --git a/lib/src/main/java/io/ably/lib/object/serialization/ObjectSerializer.java b/lib/src/main/java/io/ably/lib/object/serialization/ObjectSerializer.java new file mode 100644 index 000000000..67b21b77c --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/serialization/ObjectSerializer.java @@ -0,0 +1,98 @@ +package io.ably.lib.object.serialization; + +import com.google.gson.JsonArray; +import io.ably.lib.util.Log; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.msgpack.core.MessagePacker; +import org.msgpack.core.MessageUnpacker; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; + +/** + * Serializer interface for converting between objects and their MessagePack or JSON representations. + */ +public interface ObjectSerializer { + + /** + * Reads a MessagePack array from the given unpacker and deserializes it into an Object array. + * + * @param unpacker the MessageUnpacker to read from + * @return the deserialized Object array + * @throws IOException if an I/O error occurs during unpacking + */ + @NotNull + Object[] readMsgpackArray(@NotNull MessageUnpacker unpacker) throws IOException; + + /** + * Serializes the given Object array as a MessagePack array using the provided packer. + * + * @param objects the Object array to serialize + * @param packer the MessagePacker to write to + * @throws IOException if an I/O error occurs during packing + */ + void writeMsgpackArray(@NotNull Object[] objects, @NotNull MessagePacker packer) throws IOException; + + /** + * Reads a JSON array from the given {@link JsonArray} and deserializes it into an Object array. + * + * @param json the {@link JsonArray} representing the array to deserialize + * @return the deserialized Object array + */ + @NotNull + Object[] readFromJsonArray(@NotNull JsonArray json); + + /** + * Serializes the given Object array as a JSON array. + * + * @param objects the Object array to serialize + * @return the resulting JsonArray + */ + @NotNull + JsonArray asJsonArray(@NotNull Object[] objects); + + /** + * Returns the lazily-initialized, process-wide {@link ObjectSerializer} singleton, reflectively + * loaded from the LiveObjects plugin on the classpath. Returns {@code null} if the plugin is not + * present; the lookup is retried on subsequent calls until it succeeds. + * + * @return the shared {@link ObjectSerializer} instance, or {@code null} if the plugin is unavailable. + */ + @Nullable + static ObjectSerializer tryGet() { + return Holder.getSerializer(); + } + + /** + * Holds the lazily-initialized {@link ObjectSerializer} singleton. Interfaces cannot declare + * mutable static fields, so the cache lives here while {@link #tryGet()} delegates to it. + */ + final class Holder { + private static final String TAG = ObjectSerializer.Holder.class.getName(); + private static final String IMPLEMENTATION_CLASS = "io.ably.lib.object.serialization.DefaultObjectsSerializer"; + private static volatile ObjectSerializer objectsSerializer; + + private Holder() {} + + @Nullable + static ObjectSerializer getSerializer() { + if (objectsSerializer == null) { + synchronized (Holder.class) { + if (objectsSerializer == null) { // Double-Checked Locking (DCL) + try { + Class serializerClass = Class.forName(IMPLEMENTATION_CLASS); + objectsSerializer = (ObjectSerializer) serializerClass.getDeclaredConstructor().newInstance(); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | + NoSuchMethodException | + InvocationTargetException e) { + Log.w(TAG, "Failed to init ObjectsSerializer, LiveObjects plugin not included in the classpath", e); + return null; + } + } + } + } + return objectsSerializer; + } + } +} From ad10253bc5a5c177aedad03ca2914277b7e76084 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 19 Jun 2026 17:51:19 +0530 Subject: [PATCH 19/40] Retarget path-based serializers to WireObjectMessage model Point the JSON and MsgPack serializers in io.ably.lib.object.serialization at the new WireObjectMessage wire model instead of the legacy io.ably.lib.objects.ObjectMessage, so the new `object` package has no dependency on the legacy `objects` package. - DefaultSerialization: implement the new ObjectSerializer interface and (de)serialize WireObjectMessage arrays (reflectively loaded via ObjectSerializer.Holder). - Json/MsgpackSerialization: bind the Wire* types; replace legacy objectError with the object package's objectStateError (same 500/92000). - WireObjectMessage: restore the gson annotations required for wire-format fidelity - @SerializedName("object") on objectState and @JsonAdapter(WireObjectDataJsonSerializer) on WireObjectData. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/object/message/WireObjectMessage.kt | 5 + .../serialization/DefaultSerialization.kt | 18 +- .../object/serialization/JsonSerialization.kt | 27 +- .../serialization/MsgpackSerialization.kt | 252 +++++++++--------- 4 files changed, 152 insertions(+), 150 deletions(-) diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt index 6d8ccd785..b6f2f63f4 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt @@ -3,6 +3,9 @@ package io.ably.lib.`object`.message import com.google.gson.Gson import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import io.ably.lib.`object`.serialization.WireObjectDataJsonSerializer import java.nio.charset.StandardCharsets import java.util.Base64 @@ -36,6 +39,7 @@ internal enum class WireObjectsMapSemantics(val code: Int) { } /** Spec: OD1, OD2 - binary carried as base64 string on the wire */ +@JsonAdapter(WireObjectDataJsonSerializer::class) internal data class WireObjectData( val objectId: String? = null, // OD2a val string: String? = null, // OD2f @@ -145,6 +149,7 @@ internal data class WireObjectMessage( val connectionId: String? = null, // OM2c val extras: JsonObject? = null, // OM2d val operation: WireObjectOperation? = null, // OM2f + @SerializedName("object") val objectState: WireObjectState? = null, // OM2g - wire key "object" val serial: String? = null, // OM2h val serialTimestamp: Long? = null, // OM2j diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/DefaultSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/DefaultSerialization.kt index e8db5c956..f410999fd 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/DefaultSerialization.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/DefaultSerialization.kt @@ -1,19 +1,17 @@ package io.ably.lib.`object`.serialization import com.google.gson.* -import io.ably.lib.objects.* - -import io.ably.lib.objects.ObjectMessage +import io.ably.lib.`object`.message.WireObjectMessage import org.msgpack.core.MessagePacker import org.msgpack.core.MessageUnpacker /** - * Default implementation of {@link ObjectsSerializer} that handles serialization/deserialization - * of ObjectMessage arrays for both JSON and MessagePack formats using Jackson and Gson. - * Dynamically loaded by ObjectsHelper#getSerializer() to avoid hard dependencies. + * Default implementation of {@link ObjectSerializer} that handles serialization/deserialization + * of WireObjectMessage arrays for both JSON and MessagePack formats using Gson and MessagePack. + * Dynamically loaded by ObjectSerializer#tryGet() to avoid hard dependencies. */ -@Suppress("unused") // Used via reflection in ObjectsHelper -internal class DefaultObjectsSerializer : ObjectsSerializer { +@Suppress("unused") // Used via reflection in ObjectSerializer.Holder +internal class DefaultObjectsSerializer : ObjectSerializer { override fun readMsgpackArray(unpacker: MessageUnpacker): Array { val objectMessagesCount = unpacker.unpackArrayHeader() @@ -21,7 +19,7 @@ internal class DefaultObjectsSerializer : ObjectsSerializer { } override fun writeMsgpackArray(objects: Array, packer: MessagePacker) { - val objectMessages = objects.map { it as ObjectMessage } + val objectMessages = objects.map { it as WireObjectMessage } packer.packArrayHeader(objectMessages.size) objectMessages.forEach { it.writeMsgpack(packer) } } @@ -34,7 +32,7 @@ internal class DefaultObjectsSerializer : ObjectsSerializer { } override fun asJsonArray(objects: Array): JsonArray { - val objectMessages = objects.map { it as ObjectMessage } + val objectMessages = objects.map { it as WireObjectMessage } val jsonArray = JsonArray() for (objectMessage in objectMessages) { jsonArray.add(objectMessage.toJsonObject()) diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/JsonSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/JsonSerialization.kt index 07ef60cb7..cc5098cc5 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/JsonSerialization.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/JsonSerialization.kt @@ -1,26 +1,25 @@ package io.ably.lib.`object`.serialization import com.google.gson.* -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.serialization.EnumCodeTypeAdapter +import io.ably.lib.`object`.message.WireObjectData +import io.ably.lib.`object`.message.WireObjectMessage +import io.ably.lib.`object`.message.WireObjectOperationAction +import io.ably.lib.`object`.message.WireObjectsMapSemantics import java.lang.reflect.Type import kotlin.enums.EnumEntries // Gson instance for JSON serialization/deserialization internal val gson = GsonBuilder() - .registerTypeAdapter(ObjectOperationAction::class.java, EnumCodeTypeAdapter({ it.code }, ObjectOperationAction.entries)) - .registerTypeAdapter(ObjectsMapSemantics::class.java, EnumCodeTypeAdapter({ it.code }, ObjectsMapSemantics.entries)) + .registerTypeAdapter(WireObjectOperationAction::class.java, EnumCodeTypeAdapter({ it.code }, WireObjectOperationAction.entries)) + .registerTypeAdapter(WireObjectsMapSemantics::class.java, EnumCodeTypeAdapter({ it.code }, WireObjectsMapSemantics.entries)) .create() -internal fun ObjectMessage.toJsonObject(): JsonObject { +internal fun WireObjectMessage.toJsonObject(): JsonObject { return gson.toJsonTree(this).asJsonObject } -internal fun JsonObject.toObjectMessage(): ObjectMessage { - return gson.fromJson(this, ObjectMessage::class.java) +internal fun JsonObject.toObjectMessage(): WireObjectMessage { + return gson.fromJson(this, WireObjectMessage::class.java) } internal class EnumCodeTypeAdapter>( @@ -39,8 +38,8 @@ internal class EnumCodeTypeAdapter>( } } -internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeserializer { - override fun serialize(src: ObjectData, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { +internal class WireObjectDataJsonSerializer : JsonSerializer, JsonDeserializer { + override fun serialize(src: WireObjectData, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { val obj = JsonObject() src.objectId?.let { obj.addProperty("objectId", it) } src.string?.let { obj.addProperty("string", it) } @@ -51,7 +50,7 @@ internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeseri return obj } - override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): ObjectData { + override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): WireObjectData { val obj = if (json.isJsonObject) json.asJsonObject else throw JsonParseException("Expected JsonObject") val objectId = if (obj.has("objectId")) obj.get("objectId").asString else null val string = if (obj.has("string")) obj.get("string").asString else null @@ -63,6 +62,6 @@ internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeseri if (objectId == null && string == null && number == null && boolean == null && bytes == null && json == null) { throw JsonParseException("Since objectId is not present, at least one of the value fields must be present") } - return ObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json) + return WireObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json) } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/MsgpackSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/MsgpackSerialization.kt index 52e3ef533..0e5648002 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/MsgpackSerialization.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/MsgpackSerialization.kt @@ -3,35 +3,35 @@ package io.ably.lib.`object`.serialization import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterCreateWithObjectId -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapCreateWithObjectId -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.MapClear -import io.ably.lib.objects.ObjectDelete -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectsCounter -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectsMap -import io.ably.lib.objects.ObjectsMapEntry -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectState -import java.util.Base64 +import io.ably.lib.`object`.message.WireCounterCreate +import io.ably.lib.`object`.message.WireCounterCreateWithObjectId +import io.ably.lib.`object`.message.WireCounterInc +import io.ably.lib.`object`.message.WireMapClear +import io.ably.lib.`object`.message.WireMapCreate +import io.ably.lib.`object`.message.WireMapCreateWithObjectId +import io.ably.lib.`object`.message.WireMapRemove +import io.ably.lib.`object`.message.WireMapSet +import io.ably.lib.`object`.message.WireObjectData +import io.ably.lib.`object`.message.WireObjectDelete +import io.ably.lib.`object`.message.WireObjectMessage +import io.ably.lib.`object`.message.WireObjectOperation +import io.ably.lib.`object`.message.WireObjectOperationAction +import io.ably.lib.`object`.message.WireObjectState +import io.ably.lib.`object`.message.WireObjectsCounter +import io.ably.lib.`object`.message.WireObjectsMap +import io.ably.lib.`object`.message.WireObjectsMapEntry +import io.ably.lib.`object`.message.WireObjectsMapSemantics +import io.ably.lib.`object`.objectStateError import io.ably.lib.util.Serialisation +import java.util.Base64 import org.msgpack.core.MessageFormat import org.msgpack.core.MessagePacker import org.msgpack.core.MessageUnpacker /** - * Write ObjectMessage to MessagePacker + * Write WireObjectMessage to MessagePacker */ -internal fun ObjectMessage.writeMsgpack(packer: MessagePacker) { +internal fun WireObjectMessage.writeMsgpack(packer: MessagePacker) { var fieldCount = 0 if (id != null) fieldCount++ @@ -99,12 +99,12 @@ internal fun ObjectMessage.writeMsgpack(packer: MessagePacker) { } /** - * Read an ObjectMessage from MessageUnpacker + * Read a WireObjectMessage from MessageUnpacker */ -internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage { +internal fun readObjectMessage(unpacker: MessageUnpacker): WireObjectMessage { if (unpacker.nextFormat == MessageFormat.NIL) { unpacker.unpackNil() - return ObjectMessage() // default/empty message + return WireObjectMessage() // default/empty message } val fieldCount = unpacker.unpackMapHeader() @@ -114,8 +114,8 @@ internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage { var clientId: String? = null var connectionId: String? = null var extras: JsonObject? = null - var operation: ObjectOperation? = null - var objectState: ObjectState? = null + var operation: WireObjectOperation? = null + var objectState: WireObjectState? = null var serial: String? = null var serialTimestamp: Long? = null var siteCode: String? = null @@ -144,7 +144,7 @@ internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage { } } - return ObjectMessage( + return WireObjectMessage( id = id, timestamp = timestamp, clientId = clientId, @@ -159,9 +159,9 @@ internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage { } /** - * Write ObjectOperation to MessagePacker + * Write WireObjectOperation to MessagePacker */ -private fun ObjectOperation.writeMsgpack(packer: MessagePacker) { +private fun WireObjectOperation.writeMsgpack(packer: MessagePacker) { var fieldCount = 1 // action is always required require(objectId.isNotEmpty()) { "objectId must be non-empty per Objects protocol" } fieldCount++ @@ -233,22 +233,22 @@ private fun ObjectOperation.writeMsgpack(packer: MessagePacker) { } /** - * Read ObjectOperation from MessageUnpacker + * Read WireObjectOperation from MessageUnpacker */ -private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation { +private fun readObjectOperation(unpacker: MessageUnpacker): WireObjectOperation { val fieldCount = unpacker.unpackMapHeader() - var action: ObjectOperationAction? = null + var action: WireObjectOperationAction? = null var objectId: String = "" - var mapCreate: MapCreate? = null - var mapSet: MapSet? = null - var mapRemove: MapRemove? = null - var counterCreate: CounterCreate? = null - var counterInc: CounterInc? = null - var objectDelete: ObjectDelete? = null - var mapCreateWithObjectId: MapCreateWithObjectId? = null - var counterCreateWithObjectId: CounterCreateWithObjectId? = null - var mapClear: MapClear? = null + var mapCreate: WireMapCreate? = null + var mapSet: WireMapSet? = null + var mapRemove: WireMapRemove? = null + var counterCreate: WireCounterCreate? = null + var counterInc: WireCounterInc? = null + var objectDelete: WireObjectDelete? = null + var mapCreateWithObjectId: WireMapCreateWithObjectId? = null + var counterCreateWithObjectId: WireCounterCreateWithObjectId? = null + var mapClear: WireMapClear? = null for (i in 0 until fieldCount) { val fieldName = unpacker.unpackString().intern() @@ -262,9 +262,9 @@ private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation { when (fieldName) { "action" -> { val actionCode = unpacker.unpackInt() - action = ObjectOperationAction.entries.firstOrNull { it.code == actionCode } - ?: ObjectOperationAction.entries.firstOrNull { it.code == -1 } - ?: throw objectError("Unknown ObjectOperationAction code: $actionCode and no Unknown fallback found") + action = WireObjectOperationAction.entries.firstOrNull { it.code == actionCode } + ?: WireObjectOperationAction.entries.firstOrNull { it.code == -1 } + ?: throw objectStateError("Unknown WireObjectOperationAction code: $actionCode and no Unknown fallback found") } "objectId" -> objectId = unpacker.unpackString() "mapCreate" -> mapCreate = readMapCreate(unpacker) @@ -274,23 +274,23 @@ private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation { "counterInc" -> counterInc = readCounterInc(unpacker) "objectDelete" -> { unpacker.skipValue() // empty map, just consume it - objectDelete = ObjectDelete + objectDelete = WireObjectDelete } "mapCreateWithObjectId" -> mapCreateWithObjectId = readMapCreateWithObjectId(unpacker) "counterCreateWithObjectId" -> counterCreateWithObjectId = readCounterCreateWithObjectId(unpacker) "mapClear" -> { unpacker.skipValue() // empty map, consume it - mapClear = MapClear + mapClear = WireMapClear } else -> unpacker.skipValue() } } if (action == null) { - throw objectError("Missing required 'action' field in ObjectOperation") + throw objectStateError("Missing required 'action' field in WireObjectOperation") } - return ObjectOperation( + return WireObjectOperation( action = action, objectId = objectId, mapCreate = mapCreate, @@ -306,9 +306,9 @@ private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation { } /** - * Write ObjectState to MessagePacker + * Write WireObjectState to MessagePacker */ -private fun ObjectState.writeMsgpack(packer: MessagePacker) { +private fun WireObjectState.writeMsgpack(packer: MessagePacker) { var fieldCount = 3 // objectId, siteTimeserials, and tombstone are required if (createOp != null) fieldCount++ @@ -347,17 +347,17 @@ private fun ObjectState.writeMsgpack(packer: MessagePacker) { } /** - * Read ObjectState from MessageUnpacker + * Read WireObjectState from MessageUnpacker */ -private fun readObjectState(unpacker: MessageUnpacker): ObjectState { +private fun readObjectState(unpacker: MessageUnpacker): WireObjectState { val fieldCount = unpacker.unpackMapHeader() var objectId = "" var siteTimeserials = mapOf() var tombstone = false - var createOp: ObjectOperation? = null - var map: ObjectsMap? = null - var counter: ObjectsCounter? = null + var createOp: WireObjectOperation? = null + var map: WireObjectsMap? = null + var counter: WireObjectsCounter? = null for (i in 0 until fieldCount) { val fieldName = unpacker.unpackString().intern() @@ -388,7 +388,7 @@ private fun readObjectState(unpacker: MessageUnpacker): ObjectState { } } - return ObjectState( + return WireObjectState( objectId = objectId, siteTimeserials = siteTimeserials, tombstone = tombstone, @@ -399,9 +399,9 @@ private fun readObjectState(unpacker: MessageUnpacker): ObjectState { } /** - * Write MapCreate to MessagePacker + * Write WireMapCreate to MessagePacker */ -private fun MapCreate.writeMsgpack(packer: MessagePacker) { +private fun WireMapCreate.writeMsgpack(packer: MessagePacker) { packer.packMapHeader(2) packer.packString("semantics") packer.packInt(semantics.code) @@ -414,12 +414,12 @@ private fun MapCreate.writeMsgpack(packer: MessagePacker) { } /** - * Read MapCreate from MessageUnpacker + * Read WireMapCreate from MessageUnpacker */ -private fun readMapCreate(unpacker: MessageUnpacker): MapCreate { +private fun readMapCreate(unpacker: MessageUnpacker): WireMapCreate { val fieldCount = unpacker.unpackMapHeader() - var semantics: ObjectsMapSemantics = ObjectsMapSemantics.LWW - var entries: Map = emptyMap() + var semantics: WireObjectsMapSemantics = WireObjectsMapSemantics.LWW + var entries: Map = emptyMap() for (i in 0 until fieldCount) { val fieldName = unpacker.unpackString().intern() @@ -428,13 +428,13 @@ private fun readMapCreate(unpacker: MessageUnpacker): MapCreate { when (fieldName) { "semantics" -> { val code = unpacker.unpackInt() - semantics = ObjectsMapSemantics.entries.firstOrNull { it.code == code } - ?: ObjectsMapSemantics.entries.firstOrNull { it.code == -1 } - ?: throw objectError("Unknown MapSemantics code: $code and no UNKNOWN fallback found") + semantics = WireObjectsMapSemantics.entries.firstOrNull { it.code == code } + ?: WireObjectsMapSemantics.entries.firstOrNull { it.code == -1 } + ?: throw objectStateError("Unknown MapSemantics code: $code and no UNKNOWN fallback found") } "entries" -> { val mapSize = unpacker.unpackMapHeader() - val tempMap = mutableMapOf() + val tempMap = mutableMapOf() for (j in 0 until mapSize) { tempMap[unpacker.unpackString()] = readObjectMapEntry(unpacker) } @@ -443,13 +443,13 @@ private fun readMapCreate(unpacker: MessageUnpacker): MapCreate { else -> unpacker.skipValue() } } - return MapCreate(semantics = semantics, entries = entries) + return WireMapCreate(semantics = semantics, entries = entries) } /** - * Write MapSet to MessagePacker + * Write WireMapSet to MessagePacker */ -private fun MapSet.writeMsgpack(packer: MessagePacker) { +private fun WireMapSet.writeMsgpack(packer: MessagePacker) { packer.packMapHeader(2) packer.packString("key") packer.packString(key) @@ -458,12 +458,12 @@ private fun MapSet.writeMsgpack(packer: MessagePacker) { } /** - * Read MapSet from MessageUnpacker + * Read WireMapSet from MessageUnpacker */ -private fun readMapSet(unpacker: MessageUnpacker): MapSet { +private fun readMapSet(unpacker: MessageUnpacker): WireMapSet { val fieldCount = unpacker.unpackMapHeader() var key: String? = null - var value: ObjectData? = null + var value: WireObjectData? = null for (i in 0 until fieldCount) { val fieldName = unpacker.unpackString().intern() @@ -475,25 +475,25 @@ private fun readMapSet(unpacker: MessageUnpacker): MapSet { else -> unpacker.skipValue() } } - return MapSet( - key = key ?: throw objectError("Missing 'key' in MapSet payload"), - value = value ?: throw objectError("Missing 'value' in MapSet payload") + return WireMapSet( + key = key ?: throw objectStateError("Missing 'key' in WireMapSet payload"), + value = value ?: throw objectStateError("Missing 'value' in WireMapSet payload") ) } /** - * Write MapRemove to MessagePacker + * Write WireMapRemove to MessagePacker */ -private fun MapRemove.writeMsgpack(packer: MessagePacker) { +private fun WireMapRemove.writeMsgpack(packer: MessagePacker) { packer.packMapHeader(1) packer.packString("key") packer.packString(key) } /** - * Read MapRemove from MessageUnpacker + * Read WireMapRemove from MessageUnpacker */ -private fun readMapRemove(unpacker: MessageUnpacker): MapRemove { +private fun readMapRemove(unpacker: MessageUnpacker): WireMapRemove { val fieldCount = unpacker.unpackMapHeader() var key: String? = null @@ -506,22 +506,22 @@ private fun readMapRemove(unpacker: MessageUnpacker): MapRemove { else -> unpacker.skipValue() } } - return MapRemove(key = key ?: throw objectError("Missing 'key' in MapRemove payload")) + return WireMapRemove(key = key ?: throw objectStateError("Missing 'key' in WireMapRemove payload")) } /** - * Write CounterCreate to MessagePacker + * Write WireCounterCreate to MessagePacker */ -private fun CounterCreate.writeMsgpack(packer: MessagePacker) { +private fun WireCounterCreate.writeMsgpack(packer: MessagePacker) { packer.packMapHeader(1) packer.packString("count") packer.packDouble(count) } /** - * Read CounterCreate from MessageUnpacker + * Read WireCounterCreate from MessageUnpacker */ -private fun readCounterCreate(unpacker: MessageUnpacker): CounterCreate { +private fun readCounterCreate(unpacker: MessageUnpacker): WireCounterCreate { val fieldCount = unpacker.unpackMapHeader() var count: Double? = null @@ -534,22 +534,22 @@ private fun readCounterCreate(unpacker: MessageUnpacker): CounterCreate { else -> unpacker.skipValue() } } - return CounterCreate(count = count ?: throw objectError("Missing 'count' in CounterCreate payload")) + return WireCounterCreate(count = count ?: throw objectStateError("Missing 'count' in WireCounterCreate payload")) } /** - * Write CounterInc to MessagePacker + * Write WireCounterInc to MessagePacker */ -private fun CounterInc.writeMsgpack(packer: MessagePacker) { +private fun WireCounterInc.writeMsgpack(packer: MessagePacker) { packer.packMapHeader(1) packer.packString("number") packer.packDouble(number) } /** - * Read CounterInc from MessageUnpacker + * Read WireCounterInc from MessageUnpacker */ -private fun readCounterInc(unpacker: MessageUnpacker): CounterInc { +private fun readCounterInc(unpacker: MessageUnpacker): WireCounterInc { val fieldCount = unpacker.unpackMapHeader() var number: Double? = null @@ -562,13 +562,13 @@ private fun readCounterInc(unpacker: MessageUnpacker): CounterInc { else -> unpacker.skipValue() } } - return CounterInc(number = number ?: throw objectError("Missing 'number' in CounterInc payload")) + return WireCounterInc(number = number ?: throw objectStateError("Missing 'number' in WireCounterInc payload")) } /** - * Write MapCreateWithObjectId to MessagePacker + * Write WireMapCreateWithObjectId to MessagePacker */ -private fun MapCreateWithObjectId.writeMsgpack(packer: MessagePacker) { +private fun WireMapCreateWithObjectId.writeMsgpack(packer: MessagePacker) { packer.packMapHeader(2) packer.packString("initialValue") packer.packString(initialValue) @@ -577,9 +577,9 @@ private fun MapCreateWithObjectId.writeMsgpack(packer: MessagePacker) { } /** - * Read MapCreateWithObjectId from MessageUnpacker + * Read WireMapCreateWithObjectId from MessageUnpacker */ -private fun readMapCreateWithObjectId(unpacker: MessageUnpacker): MapCreateWithObjectId { +private fun readMapCreateWithObjectId(unpacker: MessageUnpacker): WireMapCreateWithObjectId { val fieldCount = unpacker.unpackMapHeader() var initialValue: String? = null var nonce: String? = null @@ -594,16 +594,16 @@ private fun readMapCreateWithObjectId(unpacker: MessageUnpacker): MapCreateWithO else -> unpacker.skipValue() } } - return MapCreateWithObjectId( - initialValue = initialValue ?: throw objectError("Missing 'initialValue' in MapCreateWithObjectId payload"), - nonce = nonce ?: throw objectError("Missing 'nonce' in MapCreateWithObjectId payload") + return WireMapCreateWithObjectId( + initialValue = initialValue ?: throw objectStateError("Missing 'initialValue' in WireMapCreateWithObjectId payload"), + nonce = nonce ?: throw objectStateError("Missing 'nonce' in WireMapCreateWithObjectId payload") ) } /** - * Write CounterCreateWithObjectId to MessagePacker + * Write WireCounterCreateWithObjectId to MessagePacker */ -private fun CounterCreateWithObjectId.writeMsgpack(packer: MessagePacker) { +private fun WireCounterCreateWithObjectId.writeMsgpack(packer: MessagePacker) { packer.packMapHeader(2) packer.packString("initialValue") packer.packString(initialValue) @@ -612,9 +612,9 @@ private fun CounterCreateWithObjectId.writeMsgpack(packer: MessagePacker) { } /** - * Read CounterCreateWithObjectId from MessageUnpacker + * Read WireCounterCreateWithObjectId from MessageUnpacker */ -private fun readCounterCreateWithObjectId(unpacker: MessageUnpacker): CounterCreateWithObjectId { +private fun readCounterCreateWithObjectId(unpacker: MessageUnpacker): WireCounterCreateWithObjectId { val fieldCount = unpacker.unpackMapHeader() var initialValue: String? = null var nonce: String? = null @@ -629,16 +629,16 @@ private fun readCounterCreateWithObjectId(unpacker: MessageUnpacker): CounterCre else -> unpacker.skipValue() } } - return CounterCreateWithObjectId( - initialValue = initialValue ?: throw objectError("Missing 'initialValue' in CounterCreateWithObjectId payload"), - nonce = nonce ?: throw objectError("Missing 'nonce' in CounterCreateWithObjectId payload") + return WireCounterCreateWithObjectId( + initialValue = initialValue ?: throw objectStateError("Missing 'initialValue' in WireCounterCreateWithObjectId payload"), + nonce = nonce ?: throw objectStateError("Missing 'nonce' in WireCounterCreateWithObjectId payload") ) } /** * Write ObjectMap to MessagePacker */ -private fun ObjectsMap.writeMsgpack(packer: MessagePacker) { +private fun WireObjectsMap.writeMsgpack(packer: MessagePacker) { var fieldCount = 0 if (semantics != null) fieldCount++ @@ -670,11 +670,11 @@ private fun ObjectsMap.writeMsgpack(packer: MessagePacker) { /** * Read ObjectMap from MessageUnpacker */ -private fun readObjectMap(unpacker: MessageUnpacker): ObjectsMap { +private fun readObjectMap(unpacker: MessageUnpacker): WireObjectsMap { val fieldCount = unpacker.unpackMapHeader() - var semantics: ObjectsMapSemantics? = null - var entries: Map? = null + var semantics: WireObjectsMapSemantics? = null + var entries: Map? = null var clearTimeserial: String? = null for (i in 0 until fieldCount) { @@ -689,13 +689,13 @@ private fun readObjectMap(unpacker: MessageUnpacker): ObjectsMap { when (fieldName) { "semantics" -> { val semanticsCode = unpacker.unpackInt() - semantics = ObjectsMapSemantics.entries.firstOrNull { it.code == semanticsCode } - ?: ObjectsMapSemantics.entries.firstOrNull { it.code == -1 } - ?: throw objectError("Unknown MapSemantics code: $semanticsCode and no UNKNOWN fallback found") + semantics = WireObjectsMapSemantics.entries.firstOrNull { it.code == semanticsCode } + ?: WireObjectsMapSemantics.entries.firstOrNull { it.code == -1 } + ?: throw objectStateError("Unknown MapSemantics code: $semanticsCode and no UNKNOWN fallback found") } "entries" -> { val mapSize = unpacker.unpackMapHeader() - val tempMap = mutableMapOf() + val tempMap = mutableMapOf() for (j in 0 until mapSize) { val key = unpacker.unpackString() val value = readObjectMapEntry(unpacker) @@ -708,13 +708,13 @@ private fun readObjectMap(unpacker: MessageUnpacker): ObjectsMap { } } - return ObjectsMap(semantics = semantics, entries = entries, clearTimeserial = clearTimeserial) + return WireObjectsMap(semantics = semantics, entries = entries, clearTimeserial = clearTimeserial) } /** * Write ObjectCounter to MessagePacker */ -private fun ObjectsCounter.writeMsgpack(packer: MessagePacker) { +private fun WireObjectsCounter.writeMsgpack(packer: MessagePacker) { var fieldCount = 0 if (count != null) fieldCount++ @@ -730,7 +730,7 @@ private fun ObjectsCounter.writeMsgpack(packer: MessagePacker) { /** * Read ObjectCounter from MessageUnpacker */ -private fun readObjectCounter(unpacker: MessageUnpacker): ObjectsCounter { +private fun readObjectCounter(unpacker: MessageUnpacker): WireObjectsCounter { val fieldCount = unpacker.unpackMapHeader() var count: Double? = null @@ -750,13 +750,13 @@ private fun readObjectCounter(unpacker: MessageUnpacker): ObjectsCounter { } } - return ObjectsCounter(count = count) + return WireObjectsCounter(count = count) } /** * Write ObjectMapEntry to MessagePacker */ -private fun ObjectsMapEntry.writeMsgpack(packer: MessagePacker) { +private fun WireObjectsMapEntry.writeMsgpack(packer: MessagePacker) { var fieldCount = 0 if (tombstone != null) fieldCount++ @@ -790,13 +790,13 @@ private fun ObjectsMapEntry.writeMsgpack(packer: MessagePacker) { /** * Read ObjectMapEntry from MessageUnpacker */ -private fun readObjectMapEntry(unpacker: MessageUnpacker): ObjectsMapEntry { +private fun readObjectMapEntry(unpacker: MessageUnpacker): WireObjectsMapEntry { val fieldCount = unpacker.unpackMapHeader() var tombstone: Boolean? = null var timeserial: String? = null var serialTimestamp: Long? = null - var data: ObjectData? = null + var data: WireObjectData? = null for (i in 0 until fieldCount) { val fieldName = unpacker.unpackString().intern() @@ -816,13 +816,13 @@ private fun readObjectMapEntry(unpacker: MessageUnpacker): ObjectsMapEntry { } } - return ObjectsMapEntry(tombstone = tombstone, timeserial = timeserial, serialTimestamp = serialTimestamp, data = data) + return WireObjectsMapEntry(tombstone = tombstone, timeserial = timeserial, serialTimestamp = serialTimestamp, data = data) } /** - * Write ObjectData to MessagePacker + * Write WireObjectData to MessagePacker */ -private fun ObjectData.writeMsgpack(packer: MessagePacker) { +private fun WireObjectData.writeMsgpack(packer: MessagePacker) { var fieldCount = 0 if (objectId != null) fieldCount++ @@ -868,9 +868,9 @@ private fun ObjectData.writeMsgpack(packer: MessagePacker) { } /** - * Read ObjectData from MessageUnpacker + * Read WireObjectData from MessageUnpacker */ -private fun readObjectData(unpacker: MessageUnpacker): ObjectData { +private fun readObjectData(unpacker: MessageUnpacker): WireObjectData { val fieldCount = unpacker.unpackMapHeader() var objectId: String? = null var string: String? = null @@ -904,5 +904,5 @@ private fun readObjectData(unpacker: MessageUnpacker): ObjectData { } } - return ObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json) + return WireObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json) } From 863f1f632c516950989d6558c27964d2229a9374 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 19 Jun 2026 17:54:53 +0530 Subject: [PATCH 20/40] Replace gson star import with explicit imports in ObjectJsonSerializer Fixes checkstyle AvoidStarImport violation on com.google.gson.*. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/object/serialization/ObjectJsonSerializer.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/src/main/java/io/ably/lib/object/serialization/ObjectJsonSerializer.java b/lib/src/main/java/io/ably/lib/object/serialization/ObjectJsonSerializer.java index 08e5d6b71..9deeb01fb 100644 --- a/lib/src/main/java/io/ably/lib/object/serialization/ObjectJsonSerializer.java +++ b/lib/src/main/java/io/ably/lib/object/serialization/ObjectJsonSerializer.java @@ -1,6 +1,12 @@ package io.ably.lib.object.serialization; -import com.google.gson.*; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; import io.ably.lib.util.Log; import java.lang.reflect.Type; From bfa574f7a52d6893c5e0e6211132633386370617 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 19 Jun 2026 17:56:16 +0530 Subject: [PATCH 21/40] Implemented `LiveObjectsPlugin` interface with relevant Factory method --- .../io/ably/lib/object/LiveObjectsPlugin.java | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java diff --git a/lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java b/lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java new file mode 100644 index 000000000..20d78fc1f --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java @@ -0,0 +1,109 @@ +package io.ably.lib.object; + +import io.ably.lib.object.adapter.AblyClientAdapter; +import io.ably.lib.object.adapter.Adapter; +import io.ably.lib.objects.RealtimeObjects; +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.ChannelState; +import io.ably.lib.types.ProtocolMessage; +import io.ably.lib.util.Log; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.InvocationTargetException; + +/** + * The LiveObjectsPlugin interface provides a mechanism for managing and interacting with + * live data objects in a real-time environment. It allows for the retrieval, disposal, and + * management of Objects instances associated with specific channel names. + */ +public interface LiveObjectsPlugin { + + /** + * Retrieves an instance of RealtimeObjects associated with the specified channel name. + * This method ensures that a RealtimeObjects instance is available for the given channel, + * creating one if it does not already exist. + * + * @param channelName the name of the channel for which the RealtimeObjects instance is to be retrieved. + * @return the RealtimeObjects instance associated with the specified channel name. + */ + @NotNull + RealtimeObjects getInstance(@NotNull String channelName); + + /** + * Handles a protocol message. + * This method is invoked whenever a protocol message is received, allowing the implementation + * to process the message and take appropriate actions. + * + * @param message the protocol message to handle. + */ + void handle(@NotNull ProtocolMessage message); + + /** + * Handles state changes for a specific channel. + * This method is invoked whenever a channel's state changes, allowing the implementation + * to update the RealtimeObjects instances accordingly based on the new state and presence of objects. + * + * @param channelName the name of the channel whose state has changed. + * @param state the new state of the channel. + * @param hasObjects flag indicates whether the channel has any associated objects. + */ + void handleStateChange(@NotNull String channelName, @NotNull ChannelState state, boolean hasObjects); + + /** + * Disposes of the RealtimeObjects instance associated with the specified channel name. + * This method removes the RealtimeObjects instance for the given channel, releasing any + * resources associated with it. + * This is invoked when ablyRealtimeClient.channels.release(channelName) is called + * + * @param channelName the name of the channel whose RealtimeObjects instance is to be removed. + */ + void dispose(@NotNull String channelName); + + /** + * Disposes of the plugin instance and all underlying resources. + * This is invoked when ablyRealtimeClient.close() is called + */ + void dispose(); + + /** + * Attempts to initialize the LiveObjects plugin by reflectively loading its implementation + * from the classpath. Returns a new plugin instance on every successful invocation, or + * {@code null} if the LiveObjects plugin is not present in the classpath. + * + * @param ablyRealtime the AblyRealtime client used to build the adapter the plugin runs against. + * @return a new {@link LiveObjectsPlugin} instance, or {@code null} if the plugin is unavailable. + */ + @Nullable + static LiveObjectsPlugin tryInitialize(@NotNull AblyRealtime ablyRealtime) { + return Factory.create(ablyRealtime); + } + + /** + * Reflectively constructs the LiveObjects plugin implementation. Lives in a nested class so the + * implementation-class name stays {@code private} (interface fields are forced {@code public}), + * mirroring {@link io.ably.lib.object.serialization.ObjectSerializer.Holder}. Unlike {@code Holder} + * this is stateless: {@link #create} returns a new instance on every call. + */ + final class Factory { + private static final String TAG = LiveObjectsPlugin.Factory.class.getName(); + private static final String IMPLEMENTATION_CLASS = "io.ably.lib.object.DefaultLiveObjectsPlugin"; + + private Factory() {} + + @Nullable + static LiveObjectsPlugin create(@NotNull AblyRealtime ablyRealtime) { + try { + Class objectsImplementation = Class.forName(IMPLEMENTATION_CLASS); + AblyClientAdapter adapter = new Adapter(ablyRealtime); + return (LiveObjectsPlugin) objectsImplementation + .getDeclaredConstructor(AblyClientAdapter.class) + .newInstance(adapter); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | + InvocationTargetException e) { + Log.i(TAG, "LiveObjects plugin not found in classpath. LiveObjects functionality will not be available.", e); + return null; + } + } + } +} From c489ac05769541257bc24ed31f9b50971bd0cd48 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 22 Jun 2026 13:14:45 +0530 Subject: [PATCH 22/40] Updated validation checks/log messages as per review comments --- .../lib/object/serialization/ObjectJsonSerializer.java | 4 ++-- .../ably/lib/object/serialization/ObjectSerializer.java | 2 +- .../ably/lib/object/serialization/MsgpackSerialization.kt | 8 ++++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/src/main/java/io/ably/lib/object/serialization/ObjectJsonSerializer.java b/lib/src/main/java/io/ably/lib/object/serialization/ObjectJsonSerializer.java index 9deeb01fb..8c8566490 100644 --- a/lib/src/main/java/io/ably/lib/object/serialization/ObjectJsonSerializer.java +++ b/lib/src/main/java/io/ably/lib/object/serialization/ObjectJsonSerializer.java @@ -18,7 +18,7 @@ public class ObjectJsonSerializer implements JsonSerializer, JsonDeser public Object[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { ObjectSerializer serializer = ObjectSerializer.tryGet(); if (serializer == null) { - Log.w(TAG, "Skipping 'state' field json deserialization because ObjectsSerializer not found."); + Log.w(TAG, "Skipping 'state' field json deserialization because ObjectSerializer not found."); return null; } if (!json.isJsonArray()) { @@ -31,7 +31,7 @@ public Object[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationC public JsonElement serialize(Object[] src, Type typeOfSrc, JsonSerializationContext context) { ObjectSerializer serializer = ObjectSerializer.tryGet(); if (serializer == null) { - Log.w(TAG, "Skipping 'state' field json serialization because ObjectsSerializer not found."); + Log.w(TAG, "Skipping 'state' field json serialization because ObjectSerializer not found."); return JsonNull.INSTANCE; } return serializer.asJsonArray(src); diff --git a/lib/src/main/java/io/ably/lib/object/serialization/ObjectSerializer.java b/lib/src/main/java/io/ably/lib/object/serialization/ObjectSerializer.java index 67b21b77c..78d237104 100644 --- a/lib/src/main/java/io/ably/lib/object/serialization/ObjectSerializer.java +++ b/lib/src/main/java/io/ably/lib/object/serialization/ObjectSerializer.java @@ -86,7 +86,7 @@ static ObjectSerializer getSerializer() { } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | InvocationTargetException e) { - Log.w(TAG, "Failed to init ObjectsSerializer, LiveObjects plugin not included in the classpath", e); + Log.w(TAG, "Failed to init ObjectSerializer, LiveObjects plugin not included in the classpath", e); return null; } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/MsgpackSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/MsgpackSerialization.kt index 0e5648002..849f41a4e 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/MsgpackSerialization.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/MsgpackSerialization.kt @@ -290,6 +290,10 @@ private fun readObjectOperation(unpacker: MessageUnpacker): WireObjectOperation throw objectStateError("Missing required 'action' field in WireObjectOperation") } + if (objectId.isEmpty()) { + throw objectStateError("Missing required 'objectId' field in WireObjectOperation") + } + return WireObjectOperation( action = action, objectId = objectId, @@ -388,6 +392,10 @@ private fun readObjectState(unpacker: MessageUnpacker): WireObjectState { } } + if (objectId.isEmpty()) { + throw objectStateError("Missing required 'objectId' field in WireObjectState") + } + return WireObjectState( objectId = objectId, siteTimeserials = siteTimeserials, From 3e8f70c99680bac8b0b6bf958951799afd59c249 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 22 Jun 2026 13:27:18 +0530 Subject: [PATCH 23/40] - Updated Adapter#getChannel with readonly way to retrieve channel - Added TODO for `resolveValueAtPath` based on review comment --- .../java/io/ably/lib/object/adapter/Adapter.java | 12 +++++++++--- .../io/ably/lib/object/path/DefaultPathObject.kt | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/src/main/java/io/ably/lib/object/adapter/Adapter.java b/lib/src/main/java/io/ably/lib/object/adapter/Adapter.java index d67485e37..e16d9b306 100644 --- a/lib/src/main/java/io/ably/lib/object/adapter/Adapter.java +++ b/lib/src/main/java/io/ably/lib/object/adapter/Adapter.java @@ -1,11 +1,13 @@ package io.ably.lib.object.adapter; import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; import io.ably.lib.realtime.ChannelBase; import io.ably.lib.realtime.Connection; import io.ably.lib.types.AblyException; import io.ably.lib.types.ClientOptions; import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.ReadOnlyMap; import io.ably.lib.util.Log; import org.jetbrains.annotations.NotNull; @@ -39,12 +41,16 @@ public long getTime() throws AblyException { @Override public @NotNull ChannelBase getChannel(@NotNull String channelName) throws AblyException { - if (ably.channels.containsKey(channelName)) { - return ably.channels.get(channelName); - } else { + // Look up via the read-only map view. Channels#get(String) would create the channel if + // absent; ReadOnlyMap only exposes get(Object), which returns null atomically for an + // unknown channel instead of silently recreating it. + final ReadOnlyMap channels = ably.channels; + final ChannelBase channel = channels.get(channelName); + if (channel == null) { Log.e(TAG, "getChannel(): channel not found: " + channelName); ErrorInfo errorInfo = new ErrorInfo("Channel not found: " + channelName, 404); throw AblyException.fromErrorInfo(errorInfo); } + return channel; } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt index 5b6dced0c..69e25298e 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt @@ -97,6 +97,6 @@ internal open class DefaultPathObject( protected fun resolveValueAtPath(path: String): ResolvedValue? { // TODO - resolve the path against the live objects graph and return the value at that position - return null + TODO("Not yet implemented") } } From 7ab9482117f5a6d92b94f5b68bb4c37222caed09 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 24 Jun 2026 15:19:21 +0530 Subject: [PATCH 24/40] Fixed `LiveObjectsPlugin#getInstance` method to return RealtimeObject --- lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java b/lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java index 20d78fc1f..4c92d7692 100644 --- a/lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java +++ b/lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java @@ -2,7 +2,6 @@ import io.ably.lib.object.adapter.AblyClientAdapter; import io.ably.lib.object.adapter.Adapter; -import io.ably.lib.objects.RealtimeObjects; import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.ChannelState; import io.ably.lib.types.ProtocolMessage; @@ -28,7 +27,7 @@ public interface LiveObjectsPlugin { * @return the RealtimeObjects instance associated with the specified channel name. */ @NotNull - RealtimeObjects getInstance(@NotNull String channelName); + RealtimeObject getInstance(@NotNull String channelName); /** * Handles a protocol message. From 83e347ced20ba904b28965883f2033cfe13384d7 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 24 Jun 2026 17:14:29 +0530 Subject: [PATCH 25/40] - Removed `io.ably.lib.objects` package and relevant tests - Added basic helper unit/integration tests to `io.ably.lib.object` --- .../java/io/ably/lib/realtime/Channel.java | 2 +- .../java/io/ably/lib/objects/Adapter.java | 45 - .../ably/lib/objects/LiveObjectsPlugin.java | 60 - .../io/ably/lib/objects/ObjectsAdapter.java | 46 - .../io/ably/lib/objects/ObjectsCallback.java | 31 - .../io/ably/lib/objects/ObjectsHelper.java | 48 - .../lib/objects/ObjectsJsonSerializer.java | 39 - .../ably/lib/objects/ObjectsSerializer.java | 50 - .../ably/lib/objects/ObjectsSubscription.java | 24 - .../io/ably/lib/objects/RealtimeObjects.java | 166 -- .../lib/objects/state/ObjectsStateChange.java | 56 - .../lib/objects/state/ObjectsStateEvent.java | 19 - .../objects/type/ObjectLifecycleChange.java | 69 - .../objects/type/ObjectLifecycleEvent.java | 16 - .../ably/lib/objects/type/ObjectUpdate.java | 27 - .../lib/objects/type/counter/LiveCounter.java | 73 - .../type/counter/LiveCounterChange.java | 56 - .../type/counter/LiveCounterUpdate.java | 80 - .../io/ably/lib/objects/type/map/LiveMap.java | 131 -- .../lib/objects/type/map/LiveMapChange.java | 56 - .../lib/objects/type/map/LiveMapUpdate.java | 66 - .../lib/objects/type/map/LiveMapValue.java | 443 ------ .../io/ably/lib/realtime/AblyRealtime.java | 5 +- .../io/ably/lib/realtime/ChannelBase.java | 13 +- .../java/io/ably/lib/realtime/Connection.java | 2 +- .../ably/lib/transport/ConnectionManager.java | 2 +- .../io/ably/lib/types/ProtocolMessage.java | 13 +- .../test/realtime/RealtimeChannelTest.java | 11 +- liveobjects/build.gradle.kts | 6 +- .../main/kotlin/io/ably/lib/object/Helpers.kt | 113 +- .../main/kotlin/io/ably/lib/object/Utils.kt | 60 + .../lib/object/message/WireObjectMessage.kt | 16 +- .../lib/objects/DefaultLiveObjectsPlugin.kt | 35 - .../lib/objects/DefaultRealtimeObjects.kt | 372 ----- .../kotlin/io/ably/lib/objects/ErrorCodes.kt | 20 - .../kotlin/io/ably/lib/objects/Helpers.kt | 178 --- .../kotlin/io/ably/lib/objects/ObjectId.kt | 79 - .../io/ably/lib/objects/ObjectMessage.kt | 545 ------- .../io/ably/lib/objects/ObjectsManager.kt | 328 ---- .../lib/objects/ObjectsOperationSource.kt | 7 - .../kotlin/io/ably/lib/objects/ObjectsPool.kt | 172 -- .../io/ably/lib/objects/ObjectsState.kt | 108 -- .../io/ably/lib/objects/ObjectsSyncTracker.kt | 63 - .../kotlin/io/ably/lib/objects/ServerTime.kt | 37 - .../main/kotlin/io/ably/lib/objects/Utils.kt | 117 -- .../serialization/DefaultSerialization.kt | 44 - .../serialization/JsonSerialization.kt | 67 - .../serialization/MsgpackSerialization.kt | 909 ----------- .../lib/objects/type/BaseRealtimeObject.kt | 231 --- .../ably/lib/objects/type/ObjectLifecycle.kt | 84 - .../type/livecounter/DefaultLiveCounter.kt | 137 -- .../LiveCounterChangeCoordinator.kt | 51 - .../type/livecounter/LiveCounterManager.kt | 134 -- .../objects/type/livemap/DefaultLiveMap.kt | 247 --- .../type/livemap/LiveMapChangeCoordinator.kt | 51 - .../lib/objects/type/livemap/LiveMapEntry.kt | 86 - .../objects/type/livemap/LiveMapManager.kt | 410 ----- .../ably/lib/{objects => object}/TestUtils.kt | 2 +- .../integration/DefaultRealtimeObjectTest.kt | 41 + .../integration/helpers/PayloadBuilder.kt | 34 +- .../integration/helpers/RestObjects.kt | 10 +- .../helpers/fixtures/CounterFixtures.kt | 4 +- .../helpers/fixtures/DataFixtures.kt | 32 +- .../helpers/fixtures/MapFixtures.kt | 26 +- .../integration/setup/IntegrationTest.kt | 4 +- .../integration/setup/Sandbox.kt | 6 +- .../{objects => object}/unit/HelpersTest.kt | 94 +- .../unit/ObjectMessageSerializationTest.kt | 19 +- .../unit/ObjectMessageSizeTest.kt | 99 +- .../io/ably/lib/object/unit/TestHelpers.kt | 49 + .../io/ably/lib/object/unit/UtilsTest.kt | 93 ++ .../unit/fixtures/ObjectMessageFixtures.kt | 75 +- .../integration/DefaultLiveCounterTest.kt | 367 ----- .../objects/integration/DefaultLiveMapTest.kt | 423 ----- .../integration/DefaultRealtimeObjectsTest.kt | 256 --- .../lib/objects/integration/helpers/Utils.kt | 40 - .../io/ably/lib/objects/unit/ObjectIdTest.kt | 75 - .../objects/unit/ObjectsSyncTrackerTest.kt | 65 - .../lib/objects/unit/RealtimeObjectsTest.kt | 14 - .../io/ably/lib/objects/unit/TestHelpers.kt | 169 -- .../io/ably/lib/objects/unit/UtilsTest.kt | 301 ---- .../objects/DefaultRealtimeObjectsTest.kt | 489 ------ .../unit/objects/ObjectsManagerTest.kt | 944 ----------- .../objects/unit/objects/ObjectsPoolTest.kt | 132 -- .../unit/type/BaseRealtimeObjectTest.kt | 172 -- .../livecounter/DefaultLiveCounterTest.kt | 262 ---- .../livecounter/LiveCounterManagerTest.kt | 356 ----- .../unit/type/livemap/DefaultLiveMapTest.kt | 276 ---- .../unit/type/livemap/LiveMapManagerTest.kt | 1388 ----------------- 89 files changed, 590 insertions(+), 12083 deletions(-) delete mode 100644 lib/src/main/java/io/ably/lib/objects/Adapter.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/ObjectsAdapter.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/RealtimeObjects.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleChange.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleEvent.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/Utils.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/Helpers.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsOperationSource.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/Utils.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/type/ObjectLifecycle.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt delete mode 100644 liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt rename liveobjects/src/test/kotlin/io/ably/lib/{objects => object}/TestUtils.kt (98%) create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/object/integration/DefaultRealtimeObjectTest.kt rename liveobjects/src/test/kotlin/io/ably/lib/{objects => object}/integration/helpers/PayloadBuilder.kt (72%) rename liveobjects/src/test/kotlin/io/ably/lib/{objects => object}/integration/helpers/RestObjects.kt (94%) rename liveobjects/src/test/kotlin/io/ably/lib/{objects => object}/integration/helpers/fixtures/CounterFixtures.kt (97%) rename liveobjects/src/test/kotlin/io/ably/lib/{objects => object}/integration/helpers/fixtures/DataFixtures.kt (65%) rename liveobjects/src/test/kotlin/io/ably/lib/{objects => object}/integration/helpers/fixtures/MapFixtures.kt (89%) rename liveobjects/src/test/kotlin/io/ably/lib/{objects => object}/integration/setup/IntegrationTest.kt (96%) rename liveobjects/src/test/kotlin/io/ably/lib/{objects => object}/integration/setup/Sandbox.kt (95%) rename liveobjects/src/test/kotlin/io/ably/lib/{objects => object}/unit/HelpersTest.kt (84%) rename liveobjects/src/test/kotlin/io/ably/lib/{objects => object}/unit/ObjectMessageSerializationTest.kt (90%) rename liveobjects/src/test/kotlin/io/ably/lib/{objects => object}/unit/ObjectMessageSizeTest.kt (70%) create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/object/unit/TestHelpers.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/object/unit/UtilsTest.kt rename liveobjects/src/test/kotlin/io/ably/lib/{objects => object}/unit/fixtures/ObjectMessageFixtures.kt (60%) delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultRealtimeObjectsTest.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultRealtimeObjectsTest.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseRealtimeObjectTest.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt delete mode 100644 liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt diff --git a/java/src/main/java/io/ably/lib/realtime/Channel.java b/java/src/main/java/io/ably/lib/realtime/Channel.java index 539d08adf..e3f21de54 100644 --- a/java/src/main/java/io/ably/lib/realtime/Channel.java +++ b/java/src/main/java/io/ably/lib/realtime/Channel.java @@ -1,6 +1,6 @@ package io.ably.lib.realtime; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.object.LiveObjectsPlugin; import io.ably.lib.types.AblyException; import io.ably.lib.types.ChannelOptions; import org.jetbrains.annotations.Nullable; diff --git a/lib/src/main/java/io/ably/lib/objects/Adapter.java b/lib/src/main/java/io/ably/lib/objects/Adapter.java deleted file mode 100644 index 76c35cc37..000000000 --- a/lib/src/main/java/io/ably/lib/objects/Adapter.java +++ /dev/null @@ -1,45 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.realtime.AblyRealtime; -import io.ably.lib.realtime.ChannelBase; -import io.ably.lib.realtime.Connection; -import io.ably.lib.types.AblyException; -import io.ably.lib.types.ClientOptions; -import io.ably.lib.types.ErrorInfo; -import io.ably.lib.util.Log; -import org.jetbrains.annotations.NotNull; - -public class Adapter implements ObjectsAdapter { - private final AblyRealtime ably; - private static final String TAG = ObjectsAdapter.class.getName(); - - public Adapter(@NotNull AblyRealtime ably) { - this.ably = ably; - } - - @Override - public @NotNull ClientOptions getClientOptions() { - return ably.options; - } - - @Override - public @NotNull Connection getConnection() { - return ably.connection; - } - - @Override - public long getTime() throws AblyException { - return ably.time(); - } - - @Override - public @NotNull ChannelBase getChannel(@NotNull String channelName) throws AblyException { - if (ably.channels.containsKey(channelName)) { - return ably.channels.get(channelName); - } else { - Log.e(TAG, "attachChannel(): channel not found: " + channelName); - ErrorInfo errorInfo = new ErrorInfo("Channel not found: " + channelName, 404); - throw AblyException.fromErrorInfo(errorInfo); - } - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java b/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java deleted file mode 100644 index 1f34cafdd..000000000 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java +++ /dev/null @@ -1,60 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.realtime.ChannelState; -import io.ably.lib.types.ProtocolMessage; -import org.jetbrains.annotations.NotNull; - -/** - * The LiveObjectsPlugin interface provides a mechanism for managing and interacting with - * live data objects in a real-time environment. It allows for the retrieval, disposal, and - * management of Objects instances associated with specific channel names. - */ -public interface LiveObjectsPlugin { - - /** - * Retrieves an instance of RealtimeObjects associated with the specified channel name. - * This method ensures that a RealtimeObjects instance is available for the given channel, - * creating one if it does not already exist. - * - * @param channelName the name of the channel for which the RealtimeObjects instance is to be retrieved. - * @return the RealtimeObjects instance associated with the specified channel name. - */ - @NotNull - RealtimeObjects getInstance(@NotNull String channelName); - - /** - * Handles a protocol message. - * This method is invoked whenever a protocol message is received, allowing the implementation - * to process the message and take appropriate actions. - * - * @param message the protocol message to handle. - */ - void handle(@NotNull ProtocolMessage message); - - /** - * Handles state changes for a specific channel. - * This method is invoked whenever a channel's state changes, allowing the implementation - * to update the RealtimeObjects instances accordingly based on the new state and presence of objects. - * - * @param channelName the name of the channel whose state has changed. - * @param state the new state of the channel. - * @param hasObjects flag indicates whether the channel has any associated objects. - */ - void handleStateChange(@NotNull String channelName, @NotNull ChannelState state, boolean hasObjects); - - /** - * Disposes of the RealtimeObjects instance associated with the specified channel name. - * This method removes the RealtimeObjects instance for the given channel, releasing any - * resources associated with it. - * This is invoked when ablyRealtimeClient.channels.release(channelName) is called - * - * @param channelName the name of the channel whose RealtimeObjects instance is to be removed. - */ - void dispose(@NotNull String channelName); - - /** - * Disposes of the plugin instance and all underlying resources. - * This is invoked when ablyRealtimeClient.close() is called - */ - void dispose(); -} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsAdapter.java b/lib/src/main/java/io/ably/lib/objects/ObjectsAdapter.java deleted file mode 100644 index b6054e71a..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsAdapter.java +++ /dev/null @@ -1,46 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.realtime.ChannelBase; -import io.ably.lib.realtime.Connection; -import io.ably.lib.types.AblyException; -import io.ably.lib.types.ClientOptions; -import org.jetbrains.annotations.Blocking; -import org.jetbrains.annotations.NotNull; - -public interface ObjectsAdapter { - /** - * Retrieves the client options configured for the Ably client. - * Used to access client configuration parameters such as echoMessages setting - * that affect the behavior of Objects operations. - * - * @return the client options containing configuration parameters - */ - @NotNull ClientOptions getClientOptions(); - - /** - * Retrieves the connection instance for handling connection state and operations. - * Used to check connection status, obtain error information, and manage - * message transmission across the Ably connection. - * - * @return the connection instance - */ - @NotNull Connection getConnection(); - - /** - * Retrieves the current time in milliseconds from the Ably server. - * Spec: RTO16 - */ - @Blocking - long getTime() throws AblyException; - - /** - * Retrieves the channel instance for the specified channel name. - * If the channel does not exist, an AblyException is thrown. - * - * @param channelName the name of the channel to retrieve - * @return the ChannelBase instance for the specified channel - * @throws AblyException if the channel is not found or cannot be retrieved - */ - @NotNull ChannelBase getChannel(@NotNull String channelName) throws AblyException; -} - diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java b/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java deleted file mode 100644 index 0afd5ef2f..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java +++ /dev/null @@ -1,31 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.types.AblyException; - -/** - * Callback interface for handling results of asynchronous Objects operations. - * Used for operations like creating LiveMaps/LiveCounters, modifying entries, and retrieving objects. - * Callbacks are executed on background threads managed by the Objects system. - * - * @param the type of the result returned by the asynchronous operation - */ -public interface ObjectsCallback { - - /** - * Called when the asynchronous operation completes successfully. - * For modification operations (set, remove, increment), result is typically Void. - * For creation/retrieval operations, result contains the created/retrieved object. - * - * @param result the result of the operation, may be null for modification operations - */ - void onSuccess(T result); - - /** - * Called when the asynchronous operation fails. - * The exception contains detailed error information including error codes and messages. - * Common errors include network issues, authentication failures, and validation errors. - * - * @param exception the exception that occurred during the operation - */ - void onError(AblyException exception); -} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java b/lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java deleted file mode 100644 index 81e7f3c08..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java +++ /dev/null @@ -1,48 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.realtime.AblyRealtime; -import io.ably.lib.util.Log; -import org.jetbrains.annotations.Nullable; - -import java.lang.reflect.InvocationTargetException; - -public class ObjectsHelper { - - private static final String TAG = ObjectsHelper.class.getName(); - private static volatile ObjectsSerializer objectsSerializer; - - @Nullable - public static LiveObjectsPlugin tryInitializeObjectsPlugin(AblyRealtime ablyRealtime) { - try { - Class objectsImplementation = Class.forName("io.ably.lib.objects.DefaultLiveObjectsPlugin"); - ObjectsAdapter adapter = new Adapter(ablyRealtime); - return (LiveObjectsPlugin) objectsImplementation - .getDeclaredConstructor(ObjectsAdapter.class) - .newInstance(adapter); - } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | - InvocationTargetException e) { - Log.i(TAG, "LiveObjects plugin not found in classpath. LiveObjects functionality will not be available.", e); - return null; - } - } - - @Nullable - public static ObjectsSerializer getSerializer() { - if (objectsSerializer == null) { - synchronized (ObjectsHelper.class) { - if (objectsSerializer == null) { // Double-Checked Locking (DCL) - try { - Class serializerClass = Class.forName("io.ably.lib.objects.serialization.DefaultObjectsSerializer"); - objectsSerializer = (ObjectsSerializer) serializerClass.getDeclaredConstructor().newInstance(); - } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | - NoSuchMethodException | - InvocationTargetException e) { - Log.w(TAG, "Failed to init ObjectsSerializer, LiveObjects plugin not included in the classpath", e); - return null; - } - } - } - } - return objectsSerializer; - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java b/lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java deleted file mode 100644 index b96954ca8..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java +++ /dev/null @@ -1,39 +0,0 @@ -package io.ably.lib.objects; - -import com.google.gson.JsonDeserializationContext; -import com.google.gson.JsonDeserializer; -import com.google.gson.JsonElement; -import com.google.gson.JsonNull; -import com.google.gson.JsonParseException; -import com.google.gson.JsonSerializationContext; -import com.google.gson.JsonSerializer; -import io.ably.lib.util.Log; - -import java.lang.reflect.Type; - -public class ObjectsJsonSerializer implements JsonSerializer, JsonDeserializer { - private static final String TAG = ObjectsJsonSerializer.class.getName(); - - @Override - public Object[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { - ObjectsSerializer serializer = ObjectsHelper.getSerializer(); - if (serializer == null) { - Log.w(TAG, "Skipping 'state' field json deserialization because ObjectsSerializer not found."); - return null; - } - if (!json.isJsonArray()) { - throw new JsonParseException("Expected a JSON array for 'state' field, but got: " + json); - } - return serializer.readFromJsonArray(json.getAsJsonArray()); - } - - @Override - public JsonElement serialize(Object[] src, Type typeOfSrc, JsonSerializationContext context) { - ObjectsSerializer serializer = ObjectsHelper.getSerializer(); - if (serializer == null) { - Log.w(TAG, "Skipping 'state' field json serialization because ObjectsSerializer not found."); - return JsonNull.INSTANCE; - } - return serializer.asJsonArray(src); - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java b/lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java deleted file mode 100644 index 9bee9a8fd..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java +++ /dev/null @@ -1,50 +0,0 @@ -package io.ably.lib.objects; - -import com.google.gson.JsonArray; -import org.jetbrains.annotations.NotNull; -import org.msgpack.core.MessagePacker; -import org.msgpack.core.MessageUnpacker; - -import java.io.IOException; - -/** - * Serializer interface for converting between objects and their MessagePack or JSON representations. - */ -public interface ObjectsSerializer { - /** - * Reads a MessagePack array from the given unpacker and deserializes it into an Object array. - * - * @param unpacker the MessageUnpacker to read from - * @return the deserialized Object array - * @throws IOException if an I/O error occurs during unpacking - */ - @NotNull - Object[] readMsgpackArray(@NotNull MessageUnpacker unpacker) throws IOException; - - /** - * Serializes the given Object array as a MessagePack array using the provided packer. - * - * @param objects the Object array to serialize - * @param packer the MessagePacker to write to - * @throws IOException if an I/O error occurs during packing - */ - void writeMsgpackArray(@NotNull Object[] objects, @NotNull MessagePacker packer) throws IOException; - - /** - * Reads a JSON array from the given {@link JsonArray} and deserializes it into an Object array. - * - * @param json the {@link JsonArray} representing the array to deserialize - * @return the deserialized Object array - */ - @NotNull - Object[] readFromJsonArray(@NotNull JsonArray json); - - /** - * Serializes the given Object array as a JSON array. - * - * @param objects the Object array to serialize - * @return the resulting JsonArray - */ - @NotNull - JsonArray asJsonArray(@NotNull Object[] objects); -} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java b/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java deleted file mode 100644 index 2b22d71d4..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java +++ /dev/null @@ -1,24 +0,0 @@ -package io.ably.lib.objects; - -/** - * Represents a objects subscription that can be unsubscribed from. - * This interface provides a way to clean up and remove subscriptions when they are no longer needed. - * Example usage: - *

- * {@code
- * ObjectsSubscription s = objects.subscribe(ObjectsStateEvent.SYNCING, new ObjectsStateListener() {});
- * // Later when done with the subscription
- * s.unsubscribe();
- * }
- * 
- * Spec: RTLO4b5 - */ -public interface ObjectsSubscription { - /** - * This method should be called when the subscription is no longer needed, - * it will make sure no further events will be sent to the subscriber and - * that references to the subscriber are cleaned up. - * Spec: RTLO4b5a - */ - void unsubscribe(); -} diff --git a/lib/src/main/java/io/ably/lib/objects/RealtimeObjects.java b/lib/src/main/java/io/ably/lib/objects/RealtimeObjects.java deleted file mode 100644 index 6e111b304..000000000 --- a/lib/src/main/java/io/ably/lib/objects/RealtimeObjects.java +++ /dev/null @@ -1,166 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.objects.state.ObjectsStateChange; -import io.ably.lib.objects.type.counter.LiveCounter; -import io.ably.lib.objects.type.map.LiveMap; -import io.ably.lib.objects.type.map.LiveMapValue; -import org.jetbrains.annotations.Blocking; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.NotNull; - -import java.util.Map; - -/** - * The RealtimeObjects interface provides methods to interact with live data objects, - * such as maps and counters, in a real-time environment. It supports both synchronous - * and asynchronous operations for retrieving and creating objects. - * - *

Implementations of this interface must be thread-safe as they may be accessed - * from multiple threads concurrently. - */ -public interface RealtimeObjects extends ObjectsStateChange { - - /** - * Retrieves the root LiveMap object. - * When called without a type variable, we return a default root type which is based on globally defined interface for Objects feature. - * A user can provide an explicit type for the getRoot method to explicitly set the type structure on this particular channel. - * This is useful when working with multiple channels with different underlying data structure. - * - * @return the root LiveMap instance. - */ - @Blocking - @NotNull - LiveMap getRoot(); - - /** - * Creates a new empty LiveMap with no entries. - * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * and returns it. - * - * @return the newly created empty LiveMap instance. - */ - @Blocking - @NotNull - LiveMap createMap(); - - /** - * Creates a new LiveMap with type-safe entries that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. - * Implements spec RTO11 : createMap(Dict entries?) - * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * using the provided data and returns it. - * - *

Example:

- *
{@code
-     * Map entries = Map.of(
-     *     "string", LiveMapValue.of("Hello"),
-     *     "number", LiveMapValue.of(42),
-     *     "boolean", LiveMapValue.of(true),
-     *     "binary", LiveMapValue.of(new byte[]{1, 2, 3}),
-     *     "array", LiveMapValue.of(new JsonArray()),
-     *     "object", LiveMapValue.of(new JsonObject()),
-     *     "counter", LiveMapValue.of(realtimeObjects.createCounter()),
-     *     "nested", LiveMapValue.of(realtimeObjects.createMap())
-     * );
-     * LiveMap map = realtimeObjects.createMap(entries);
-     * }
- * - * @param entries the type-safe map entries with values that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. - * @return the newly created LiveMap instance. - */ - @Blocking - @NotNull - LiveMap createMap(@NotNull Map entries); - - /** - * Creates a new LiveCounter with an initial value of 0. - * Send a COUNTER_CREATE operation to the realtime system to create a new counter object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * using the provided data and returns it. - * - * @return the newly created LiveCounter instance with initial value of 0. - */ - @Blocking - @NotNull - LiveCounter createCounter(); - - /** - * Creates a new LiveCounter with an initial value. - * Send a COUNTER_CREATE operation to the realtime system to create a new counter object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * using the provided data and returns it. - * - * @param initialValue the initial value of the LiveCounter. - * @return the newly created LiveCounter instance. - */ - @Blocking - @NotNull - LiveCounter createCounter(@NotNull Number initialValue); - - /** - * Asynchronously retrieves the root LiveMap object. - * When called without a type variable, we return a default root type which is based on globally defined interface for Objects feature. - * A user can provide an explicit type for the getRoot method to explicitly set the type structure on this particular channel. - * This is useful when working with multiple channels with different underlying data structure. - * - * @param callback the callback to handle the result or error. - */ - @NonBlocking - void getRootAsync(@NotNull ObjectsCallback<@NotNull LiveMap> callback); - - /** - * Asynchronously creates a new empty LiveMap with no entries. - * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * and returns it. - * - * @param callback the callback to handle the result or error. - */ - @NonBlocking - void createMapAsync(@NotNull ObjectsCallback<@NotNull LiveMap> callback); - - /** - * Asynchronously creates a new LiveMap with type-safe entries that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. - * This method implements the spec RTO11 signature: createMap(Dict entries?) - * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * using the provided data and returns it. - * - * @param entries the type-safe map entries with values that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. - * @param callback the callback to handle the result or error. - */ - @NonBlocking - void createMapAsync(@NotNull Map entries, @NotNull ObjectsCallback<@NotNull LiveMap> callback); - - /** - * Asynchronously creates a new LiveCounter with an initial value of 0. - * Send a COUNTER_CREATE operation to the realtime system to create a new counter object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * using the provided data and returns it. - * - * @param callback the callback to handle the result or error. - */ - @NonBlocking - void createCounterAsync(@NotNull ObjectsCallback<@NotNull LiveCounter> callback); - - /** - * Asynchronously creates a new LiveCounter with an initial value. - * Send a COUNTER_CREATE operation to the realtime system to create a new counter object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * using the provided data and returns it. - * - * @param initialValue the initial value of the LiveCounter. - * @param callback the callback to handle the result or error. - */ - @NonBlocking - void createCounterAsync(@NotNull Number initialValue, @NotNull ObjectsCallback<@NotNull LiveCounter> callback); -} diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java deleted file mode 100644 index 180645f3c..000000000 --- a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.ably.lib.objects.state; - -import io.ably.lib.objects.ObjectsSubscription; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.NotNull; - -public interface ObjectsStateChange { - /** - * Subscribes to a specific Objects synchronization state event. - * - *

This method registers the provided listener to be notified when the specified - * synchronization state event occurs. The returned subscription can be used to - * unsubscribe later when the notifications are no longer needed. - * - * @param event the synchronization state event to subscribe to (SYNCING or SYNCED) - * @param listener the listener that will be called when the event occurs - * @return a subscription object that can be used to unsubscribe from the event - */ - @NonBlocking - ObjectsSubscription on(@NotNull ObjectsStateEvent event, @NotNull ObjectsStateChange.Listener listener); - - /** - * Unsubscribes the specified listener from all synchronization state events. - * - *

After calling this method, the provided listener will no longer receive - * any synchronization state event notifications. - * - * @param listener the listener to unregister from all events - */ - @NonBlocking - void off(@NotNull ObjectsStateChange.Listener listener); - - /** - * Unsubscribes all listeners from all synchronization state events. - * - *

After calling this method, no listeners will receive any synchronization - * state event notifications until new listeners are registered. - */ - @NonBlocking - void offAll(); - - /** - * Interface for receiving notifications about Objects synchronization state changes. - *

- * Implement this interface and register it with an ObjectsStateEmitter to be notified - * when synchronization state transitions occur. - */ - interface Listener { - /** - * Called when the synchronization state changes. - * - * @param objectsStateEvent The new state event (SYNCING or SYNCED) - */ - void onStateChanged(ObjectsStateEvent objectsStateEvent); - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java deleted file mode 100644 index 1aa27203a..000000000 --- a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java +++ /dev/null @@ -1,19 +0,0 @@ -package io.ably.lib.objects.state; - -/** - * Represents the synchronization state of Ably Objects. - *

- * This enum is used to notify listeners about state changes in the synchronization process. - * Clients can register an {@link ObjectsStateChange.Listener} to receive these events. - */ -public enum ObjectsStateEvent { - /** - * Indicates that synchronization between local and remote objects is in progress. - */ - SYNCING, - - /** - * Indicates that synchronization has completed successfully and objects are in sync. - */ - SYNCED -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleChange.java b/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleChange.java deleted file mode 100644 index c8d0f5745..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleChange.java +++ /dev/null @@ -1,69 +0,0 @@ -package io.ably.lib.objects.type; - -import io.ably.lib.objects.ObjectsSubscription; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.NotNull; - -/** - * Interface for managing subscriptions to Object lifecycle events. - *

- * This interface provides methods to subscribe to and manage notifications about significant lifecycle - * changes that occur to Object, such as deletion. More events can be added in the future. - * Multiple listeners can be registered independently, and each can be managed separately. - *

- * Lifecycle events are different from data update events - they represent changes - * to the object's existence state rather than changes to the object's data content. - * - * @see ObjectLifecycleEvent for the available lifecycle events - */ -public interface ObjectLifecycleChange { - /** - * Subscribes to a specific Object lifecycle event. - * - *

This method registers the provided listener to be notified when the specified - * lifecycle event occurs. The returned subscription can be used to - * unsubscribe later when the notifications are no longer needed. - * - * @param event the lifecycle event to subscribe to - * @param listener the listener that will be called when the event occurs - * @return a subscription object that can be used to unsubscribe from the event - */ - @NonBlocking - ObjectsSubscription on(@NotNull ObjectLifecycleEvent event, @NotNull ObjectLifecycleChange.Listener listener); - - /** - * Unsubscribes the specified listener from all lifecycle events. - * - *

After calling this method, the provided listener will no longer receive - * any lifecycle event notifications. - * - * @param listener the listener to unregister from all events - */ - @NonBlocking - void off(@NotNull ObjectLifecycleChange.Listener listener); - - /** - * Unsubscribes all listeners from all lifecycle events. - * - *

After calling this method, no listeners will receive any lifecycle - * event notifications until new listeners are registered. - */ - @NonBlocking - void offAll(); - - /** - * Interface for receiving notifications about Object lifecycle changes. - *

- * Implement this interface and register it with an ObjectLifecycleChange provider - * to be notified when lifecycle events occur, such as object creation or deletion. - */ - @FunctionalInterface - interface Listener { - /** - * Called when a lifecycle event occurs. - * - * @param lifecycleEvent The lifecycle event that occurred - */ - void onLifecycleEvent(@NotNull ObjectLifecycleEvent lifecycleEvent); - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleEvent.java b/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleEvent.java deleted file mode 100644 index 7a2d1aa7d..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package io.ably.lib.objects.type; - -/** - * Represents lifecycle events for an Ably Object. - *

- * This enum notifies listeners about significant lifecycle changes that occur to an Object during its lifetime. - * Clients can register a {@link ObjectLifecycleChange.Listener} to receive these events. - */ -public enum ObjectLifecycleEvent { - /** - * Indicates that an Object has been deleted (tombstoned). - * Emitted once when the object is tombstoned server-side (i.e., deleted and no longer addressable). - * Not re-emitted during client-side garbage collection of tombstones. - */ - DELETED -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java b/lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java deleted file mode 100644 index 8ee1e1578..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.ably.lib.objects.type; - -import org.jetbrains.annotations.Nullable; - -/** - * Abstract base class for all LiveMap/LiveCounter update notifications. - * Provides common structure for updates that occur on LiveMap and LiveCounter objects. - * Contains the update data that describes what changed in the object. - * Spec: RTLO4b4 - */ -public abstract class ObjectUpdate { - /** - * The update data containing details about the change that occurred - * Spec: RTLO4b4a - */ - @Nullable - protected final Object update; - - /** - * Creates a ObjectUpdate with the specified update data. - * - * @param update the data describing the change, or null for no-op updates - */ - protected ObjectUpdate(@Nullable Object update) { - this.update = update; - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java deleted file mode 100644 index 958cf05b1..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java +++ /dev/null @@ -1,73 +0,0 @@ -package io.ably.lib.objects.type.counter; - -import io.ably.lib.objects.ObjectsCallback; -import io.ably.lib.objects.type.ObjectLifecycleChange; -import org.jetbrains.annotations.Blocking; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Contract; - -/** - * The LiveCounter interface provides methods to interact with a live counter. - * It allows incrementing, decrementing, and retrieving the current value of the counter, - * both synchronously and asynchronously. - */ -public interface LiveCounter extends LiveCounterChange, ObjectLifecycleChange { - - /** - * Increments the value of the counter by the specified amount. - * Send a COUNTER_INC operation to the realtime system to increment a value on this LiveCounter object. - * This does not modify the underlying data of this LiveCounter object. Instead, the change will be applied when - * the published COUNTER_INC operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLC12 - * - * @param amount the amount by which to increment the counter - */ - @Blocking - void increment(@NotNull Number amount); - - /** - * Decrements the value of the counter by the specified amount. - * An alias for calling {@link LiveCounter#increment(Number)} with a negative amount. - * Spec: RTLC13 - * - * @param amount the amount by which to decrement the counter - */ - @Blocking - void decrement(@NotNull Number amount); - - /** - * Increments the value of the counter by the specified amount asynchronously. - * Send a COUNTER_INC operation to the realtime system to increment a value on this LiveCounter object. - * This does not modify the underlying data of this LiveCounter object. Instead, the change will be applied when - * the published COUNTER_INC operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLC12 - * - * @param amount the amount by which to increment the counter - * @param callback the callback to be invoked upon completion of the operation. - */ - @NonBlocking - void incrementAsync(@NotNull Number amount, @NotNull ObjectsCallback callback); - - /** - * Decrements the value of the counter by the specified amount asynchronously. - * An alias for calling {@link LiveCounter#incrementAsync(Number, ObjectsCallback)} with a negative amount. - * Spec: RTLC13 - * - * @param amount the amount by which to decrement the counter - * @param callback the callback to be invoked upon completion of the operation. - */ - @NonBlocking - void decrementAsync(@NotNull Number amount, @NotNull ObjectsCallback callback); - - /** - * Retrieves the current value of the counter. - * - * @return the current value of the counter as a Double. - */ - @NotNull - @Contract(pure = true) // Indicates this method does not modify the state of the object. - Double value(); -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java deleted file mode 100644 index 79f842e74..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.ably.lib.objects.type.counter; - -import io.ably.lib.objects.ObjectsSubscription; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.NotNull; - -/** - * Provides methods to subscribe to real-time updates on LiveCounter objects. - * Enables clients to receive notifications when counter values change due to - * operations performed by any client connected to the same channel. - */ -public interface LiveCounterChange { - - /** - * Subscribes to real-time updates on this LiveCounter object. - * Multiple listeners can be subscribed to the same object independently. - * Spec: RTLO4b - * - * @param listener the listener to be notified of counter updates - * @return an ObjectsSubscription for managing this specific listener - */ - @NonBlocking - @NotNull ObjectsSubscription subscribe(@NotNull Listener listener); - - /** - * Unsubscribes a specific listener from receiving updates. - * Has no effect if the listener is not currently subscribed. - * Spec: RTLO4c - * - * @param listener the listener to be unsubscribed - */ - @NonBlocking - void unsubscribe(@NotNull Listener listener); - - /** - * Unsubscribes all listeners from receiving updates. - * No notifications will be delivered until new listeners are subscribed. - * Spec: RTLO4d - */ - @NonBlocking - void unsubscribeAll(); - - /** - * Listener interface for receiving LiveCounter updates. - * Spec: RTLO4b3 - */ - interface Listener { - /** - * Called when the LiveCounter has been updated. - * Should execute quickly as it's called from the real-time processing thread. - * - * @param update details about the counter change - */ - void onUpdated(@NotNull LiveCounterUpdate update); - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java deleted file mode 100644 index d7921a0b5..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java +++ /dev/null @@ -1,80 +0,0 @@ -package io.ably.lib.objects.type.counter; - -import io.ably.lib.objects.type.ObjectUpdate; -import org.jetbrains.annotations.NotNull; - -/** - * Represents an update that occurred on a LiveCounter object. - * Contains information about counter value changes from increment/decrement operations. - * Updates can represent positive changes (increments) or negative changes (decrements). - * - * @spec RTLC11, RTLC11a - LiveCounter update structure and behavior - */ -public class LiveCounterUpdate extends ObjectUpdate { - - /** - * Creates a no-op LiveCounterUpdate representing no actual change. - */ - public LiveCounterUpdate() { - super(null); - } - - /** - * Creates a LiveCounterUpdate with the specified amount change. - * - * @param amount the amount by which the counter changed (positive = increment, negative = decrement) - */ - public LiveCounterUpdate(@NotNull Double amount) { - super(new Update(amount)); - } - - /** - * Gets the update information containing the amount of change. - * - * @return the Update object with the counter modification amount - */ - @NotNull - public LiveCounterUpdate.Update getUpdate() { - return (Update) update; - } - - /** - * Returns a string representation of this LiveCounterUpdate. - * - * @return a string showing the amount of change to the counter - */ - @Override - public String toString() { - if (update == null) { - return "LiveCounterUpdate{no change}"; - } - return "LiveCounterUpdate{amount=" + getUpdate().getAmount() + "}"; - } - - /** - * Contains the specific details of a counter update operation. - * - * @spec RTLC11b, RTLC11b1 - Counter update data structure - */ - public static class Update { - private final @NotNull Double amount; - - /** - * Creates an Update with the specified amount. - * - * @param amount the counter change amount (positive = increment, negative = decrement) - */ - public Update(@NotNull Double amount) { - this.amount = amount; - } - - /** - * Gets the amount by which the counter value was modified. - * - * @return the change amount (positive for increments, negative for decrements) - */ - public @NotNull Double getAmount() { - return amount; - } - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java deleted file mode 100644 index f180fe168..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java +++ /dev/null @@ -1,131 +0,0 @@ -package io.ably.lib.objects.type.map; - -import io.ably.lib.objects.ObjectsCallback; -import io.ably.lib.objects.type.ObjectLifecycleChange; -import org.jetbrains.annotations.Blocking; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.Contract; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.Unmodifiable; - -import java.util.Map; - -/** - * The LiveMap interface provides methods to interact with a live, real-time map structure. - * It supports both synchronous and asynchronous operations for managing key-value pairs. - */ -public interface LiveMap extends LiveMapChange, ObjectLifecycleChange { - - /** - * Retrieves the value associated with the specified key. - * If this map object is tombstoned (deleted), null is returned. - * If no entry is associated with the specified key, null is returned. - * If map entry is tombstoned (deleted), null is returned. - * If the value associated with the provided key is an objectId string of another RealtimeObject, a reference to - * that RealtimeObject is returned, provided it exists in the local pool and is not tombstoned. Otherwise, null is returned. - * If the value is not an objectId, then that value is returned. - * Spec: RTLM5, RTLM5a - * - * @param keyName the key whose associated value is to be returned. - * @return the value associated with the specified key, or null if the key does not exist. - */ - @Nullable - LiveMapValue get(@NotNull String keyName); - - /** - * Retrieves all entries (key-value pairs) in the map. - * Spec: RTLM11, RTLM11a - * - * @return an iterable collection of all entries in the map. - */ - @NotNull - @Unmodifiable - Iterable> entries(); - - /** - * Retrieves all keys in the map. - * Spec: RTLM12, RTLM12a - * - * @return an iterable collection of all keys in the map. - */ - @NotNull - @Unmodifiable - Iterable keys(); - - /** - * Retrieves all values in the map. - * Spec: RTLM13, RTLM13a - * - * @return an iterable collection of all values in the map. - */ - @NotNull - @Unmodifiable - Iterable values(); - - /** - * Sets the specified key to the given value in the map. - * Send a MAP_SET operation to the realtime system to set a key on this LiveMap object to a specified value. - * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when - * the published MAP_SET operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLM20 - * - * @param keyName the key to be set. - * @param value the value to be associated with the key. - */ - @Blocking - void set(@NotNull String keyName, @NotNull LiveMapValue value); - - /** - * Removes the specified key and its associated value from the map. - * Send a MAP_REMOVE operation to the realtime system to tombstone a key on this LiveMap object. - * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when - * the published MAP_REMOVE operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLM21 - * - * @param keyName the key to be removed. - */ - @Blocking - void remove(@NotNull String keyName); - - /** - * Retrieves the number of entries in the map. - * Spec: RTLM10, RTLM10a - * - * @return the size of the map. - */ - @Contract(pure = true) // Indicates this method does not modify the state of the object. - @NotNull - Long size(); - - /** - * Asynchronously sets the specified key to the given value in the map. - * Send a MAP_SET operation to the realtime system to set a key on this LiveMap object to a specified value. - * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when - * the published MAP_SET operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLM20 - * - * @param keyName the key to be set. - * @param value the value to be associated with the key. - * @param callback the callback to handle the result or any errors. - */ - @NonBlocking - void setAsync(@NotNull String keyName, @NotNull LiveMapValue value, @NotNull ObjectsCallback callback); - - /** - * Asynchronously removes the specified key and its associated value from the map. - * Send a MAP_REMOVE operation to the realtime system to tombstone a key on this LiveMap object. - * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when - * the published MAP_REMOVE operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLM21 - * - * @param keyName the key to be removed. - * @param callback the callback to handle the result or any errors. - */ - @NonBlocking - void removeAsync(@NotNull String keyName, @NotNull ObjectsCallback callback); -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java deleted file mode 100644 index c30ae7850..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.ably.lib.objects.type.map; - -import io.ably.lib.objects.ObjectsSubscription; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.NotNull; - -/** - * Provides methods to subscribe to real-time updates on LiveMap objects. - * Enables clients to receive notifications when map entries are added, updated, or removed. - * Uses last-write-wins conflict resolution when multiple clients modify the same key. - */ -public interface LiveMapChange { - - /** - * Subscribes to real-time updates on this LiveMap object. - * Multiple listeners can be subscribed to the same object independently. - * Spec: RTLO4b - * - * @param listener the listener to be notified of map updates - * @return an ObjectsSubscription for managing this specific listener - */ - @NonBlocking - @NotNull ObjectsSubscription subscribe(@NotNull Listener listener); - - /** - * Unsubscribes a specific listener from receiving updates. - * Has no effect if the listener is not currently subscribed. - * Spec: RTLO4c - * - * @param listener the listener to be unsubscribed - */ - @NonBlocking - void unsubscribe(@NotNull Listener listener); - - /** - * Unsubscribes all listeners from receiving updates. - * No notifications will be delivered until new listeners are subscribed. - * Spec: RTLO4d - */ - @NonBlocking - void unsubscribeAll(); - - /** - * Listener interface for receiving LiveMap updates. - * Spec: RTLO4b3 - */ - interface Listener { - /** - * Called when the LiveMap has been updated. - * Should execute quickly as it's called from the real-time processing thread. - * - * @param update details about which keys were modified and how - */ - void onUpdated(@NotNull LiveMapUpdate update); - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java deleted file mode 100644 index 08fe2fc39..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java +++ /dev/null @@ -1,66 +0,0 @@ -package io.ably.lib.objects.type.map; - -import io.ably.lib.objects.type.ObjectUpdate; -import org.jetbrains.annotations.NotNull; - -import java.util.Map; - -/** - * Represents an update that occurred on a LiveMap object. - * Contains information about which keys were modified and whether they were updated or removed. - * - * @spec RTLM18, RTLM18a - LiveMap update structure and behavior - */ -public class LiveMapUpdate extends ObjectUpdate { - - /** - * Creates a no-op LiveMapUpdate representing no actual change. - */ - public LiveMapUpdate() { - super(null); - } - - /** - * Creates a LiveMapUpdate with the specified key changes. - * - * @param update map of key names to their change types (UPDATED or REMOVED) - */ - public LiveMapUpdate(@NotNull Map update) { - super(update); - } - - /** - * Gets the map of key changes that occurred in this update. - * - * @return map of key names to their change types - */ - @NotNull - public Map getUpdate() { - return (Map) update; - } - - /** - * Returns a string representation of this LiveMapUpdate. - * - * @return a string showing the map key changes in this update - */ - @Override - public String toString() { - if (update == null) { - return "LiveMapUpdate{no change}"; - } - return "LiveMapUpdate{changes=" + getUpdate() + "}"; - } - - /** - * Indicates the type of change that occurred to a map key. - * - * @spec RTLM18b - Map change types - */ - public enum Change { - /** The key was added or its value was modified */ - UPDATED, - /** The key was removed from the map */ - REMOVED - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java deleted file mode 100644 index ccba80330..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java +++ /dev/null @@ -1,443 +0,0 @@ -package io.ably.lib.objects.type.map; - -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import io.ably.lib.objects.type.counter.LiveCounter; -import org.jetbrains.annotations.NotNull; - -/** - * Abstract class representing the union type for LiveMap values. - * Provides strict compile-time type safety, implementation is similar to Gson's JsonElement pattern. - * Spec: RTO11a1 - Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap - */ -public abstract class LiveMapValue { - - /** - * Gets the underlying value. - * - * @return the value as an Object - */ - @NotNull - public abstract Object getValue(); - - /** - * Type checking methods with default implementations - */ - - /** - * Returns true if this LiveMapValue represents a Boolean value. - * - * @return true if this is a Boolean value - */ - public boolean isBoolean() { return false; } - - /** - * Returns true if this LiveMapValue represents a Binary value. - * - * @return true if this is a Binary value - */ - public boolean isBinary() { return false; } - - /** - * Returns true if this LiveMapValue represents a Number value. - * - * @return true if this is a Number value - */ - public boolean isNumber() { return false; } - - /** - * Returns true if this LiveMapValue represents a String value. - * - * @return true if this is a String value - */ - public boolean isString() { return false; } - - /** - * Returns true if this LiveMapValue represents a JsonArray value. - * - * @return true if this is a JsonArray value - */ - public boolean isJsonArray() { return false; } - - /** - * Returns true if this LiveMapValue represents a JsonObject value. - * - * @return true if this is a JsonObject value - */ - public boolean isJsonObject() { return false; } - - /** - * Returns true if this LiveMapValue represents a LiveCounter value. - * - * @return true if this is a LiveCounter value - */ - public boolean isLiveCounter() { return false; } - - /** - * Returns true if this LiveMapValue represents a LiveMap value. - * - * @return true if this is a LiveMap value - */ - public boolean isLiveMap() { return false; } - - /** - * Getter methods with default implementations that throw exceptions - */ - - /** - * Gets the Boolean value if this LiveMapValue represents a Boolean. - * - * @return the Boolean value - * @throws IllegalStateException if this is not a Boolean value - */ - @NotNull - public Boolean getAsBoolean() { - throw new IllegalStateException("Not a Boolean value"); - } - - /** - * Gets the Binary value if this LiveMapValue represents a Binary. - * - * @return the Binary value - * @throws IllegalStateException if this is not a Binary value - */ - public byte @NotNull [] getAsBinary() { - throw new IllegalStateException("Not a Binary value"); - } - - /** - * Gets the Number value if this LiveMapValue represents a Number. - * - * @return the Number value - * @throws IllegalStateException if this is not a Number value - */ - @NotNull - public Number getAsNumber() { - throw new IllegalStateException("Not a Number value"); - } - - /** - * Gets the String value if this LiveMapValue represents a String. - * - * @return the String value - * @throws IllegalStateException if this is not a String value - */ - @NotNull - public String getAsString() { - throw new IllegalStateException("Not a String value"); - } - - /** - * Gets the JsonArray value if this LiveMapValue represents a JsonArray. - * - * @return the JsonArray value - * @throws IllegalStateException if this is not a JsonArray value - */ - @NotNull - public JsonArray getAsJsonArray() { - throw new IllegalStateException("Not a JsonArray value"); - } - - /** - * Gets the JsonObject value if this LiveMapValue represents a JsonObject. - * - * @return the JsonObject value - * @throws IllegalStateException if this is not a JsonObject value - */ - @NotNull - public JsonObject getAsJsonObject() { - throw new IllegalStateException("Not a JsonObject value"); - } - - /** - * Gets the LiveCounter value if this LiveMapValue represents a LiveCounter. - * - * @return the LiveCounter value - * @throws IllegalStateException if this is not a LiveCounter value - */ - @NotNull - public LiveCounter getAsLiveCounter() { - throw new IllegalStateException("Not a LiveCounter value"); - } - - /** - * Gets the LiveMap value if this LiveMapValue represents a LiveMap. - * - * @return the LiveMap value - * @throws IllegalStateException if this is not a LiveMap value - */ - @NotNull - public LiveMap getAsLiveMap() { - throw new IllegalStateException("Not a LiveMap value"); - } - - /** - * Static factory methods similar to JsonElement constructors - */ - - /** - * Creates a LiveMapValue from a Boolean. - * - * @param value the boolean value - * @return a LiveMapValue containing the boolean - */ - @NotNull - public static LiveMapValue of(@NotNull Boolean value) { - return new BooleanValue(value); - } - - /** - * Creates a LiveMapValue from a Binary. - * - * @param value the binary value - * @return a LiveMapValue containing the binary - */ - @NotNull - public static LiveMapValue of(byte @NotNull [] value) { - return new BinaryValue(value); - } - - /** - * Creates a LiveMapValue from a Number. - * - * @param value the number value - * @return a LiveMapValue containing the number - */ - @NotNull - public static LiveMapValue of(@NotNull Number value) { - return new NumberValue(value); - } - - /** - * Creates a LiveMapValue from a String. - * - * @param value the string value - * @return a LiveMapValue containing the string - */ - @NotNull - public static LiveMapValue of(@NotNull String value) { - return new StringValue(value); - } - - /** - * Creates a LiveMapValue from a JsonArray. - * - * @param value the JsonArray value - * @return a LiveMapValue containing the JsonArray - */ - @NotNull - public static LiveMapValue of(@NotNull JsonArray value) { - return new JsonArrayValue(value); - } - - /** - * Creates a LiveMapValue from a JsonObject. - * - * @param value the JsonObject value - * @return a LiveMapValue containing the JsonObject - */ - @NotNull - public static LiveMapValue of(@NotNull JsonObject value) { - return new JsonObjectValue(value); - } - - /** - * Creates a LiveMapValue from a LiveCounter. - * - * @param value the LiveCounter value - * @return a LiveMapValue containing the LiveCounter - */ - @NotNull - public static LiveMapValue of(@NotNull LiveCounter value) { - return new LiveCounterValue(value); - } - - /** - * Creates a LiveMapValue from a LiveMap. - * - * @param value the LiveMap value - * @return a LiveMapValue containing the LiveMap - */ - @NotNull - public static LiveMapValue of(@NotNull LiveMap value) { - return new LiveMapValueWrapper(value); - } - - // Concrete implementations for each allowed type - - /** - * Boolean value implementation. - */ - private static final class BooleanValue extends LiveMapValue { - private final Boolean value; - - BooleanValue(@NotNull Boolean value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isBoolean() { return true; } - - @Override - public @NotNull Boolean getAsBoolean() { return value; } - } - - /** - * Binary value implementation. - */ - private static final class BinaryValue extends LiveMapValue { - private final byte[] value; - - BinaryValue(byte @NotNull [] value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isBinary() { return true; } - - @Override - public byte @NotNull [] getAsBinary() { return value; } - } - - /** - * Number value implementation. - */ - private static final class NumberValue extends LiveMapValue { - private final Number value; - - NumberValue(@NotNull Number value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isNumber() { return true; } - - @Override - public @NotNull Number getAsNumber() { return value; } - } - - /** - * String value implementation. - */ - private static final class StringValue extends LiveMapValue { - private final String value; - - StringValue(@NotNull String value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isString() { return true; } - - @Override - public @NotNull String getAsString() { return value; } - } - - /** - * JsonArray value implementation. - */ - private static final class JsonArrayValue extends LiveMapValue { - private final JsonArray value; - - JsonArrayValue(@NotNull JsonArray value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isJsonArray() { return true; } - - @Override - public @NotNull JsonArray getAsJsonArray() { return value; } - } - - /** - * JsonObject value implementation. - */ - private static final class JsonObjectValue extends LiveMapValue { - private final JsonObject value; - - JsonObjectValue(@NotNull JsonObject value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isJsonObject() { return true; } - - @Override - public @NotNull JsonObject getAsJsonObject() { return value; } - } - - /** - * LiveCounter value implementation. - */ - private static final class LiveCounterValue extends LiveMapValue { - private final LiveCounter value; - - LiveCounterValue(@NotNull LiveCounter value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isLiveCounter() { return true; } - - @Override - public @NotNull LiveCounter getAsLiveCounter() { return value; } - } - - /** - * LiveMap value implementation. - */ - private static final class LiveMapValueWrapper extends LiveMapValue { - private final LiveMap value; - - LiveMapValueWrapper(@NotNull LiveMap value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isLiveMap() { return true; } - - @Override - public @NotNull LiveMap getAsLiveMap() { return value; } - } -} diff --git a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java index d9053c8d2..3d1c4a663 100644 --- a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java +++ b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java @@ -5,8 +5,7 @@ import java.util.List; import java.util.Map; -import io.ably.lib.objects.ObjectsHelper; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.object.LiveObjectsPlugin; import io.ably.lib.rest.AblyRest; import io.ably.lib.rest.Auth; import io.ably.lib.transport.ConnectionManager; @@ -74,7 +73,7 @@ public AblyRealtime(ClientOptions options) throws AblyException { final InternalChannels channels = new InternalChannels(); this.channels = channels; - liveObjectsPlugin = ObjectsHelper.tryInitializeObjectsPlugin(this); + liveObjectsPlugin = LiveObjectsPlugin.tryInitialize(this); connection = new Connection(this, channels, platformAgentProvider, liveObjectsPlugin); diff --git a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java index d521f0b4a..3b9d4c41f 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java +++ b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java @@ -14,8 +14,7 @@ import io.ably.lib.http.HttpCore; import io.ably.lib.http.HttpUtils; import io.ably.lib.object.RealtimeObject; -import io.ably.lib.objects.RealtimeObjects; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.object.LiveObjectsPlugin; import io.ably.lib.rest.MessageEditsMixin; import io.ably.lib.rest.RestAnnotations; import io.ably.lib.transport.ConnectionManager; @@ -115,16 +114,6 @@ public abstract class ChannelBase extends EventEmitter') to your dependency tree", 400, 40019) - ); - } - return liveObjectsPlugin.getInstance(name); - } - public final RealtimeAnnotations annotations; /*** diff --git a/lib/src/main/java/io/ably/lib/realtime/Connection.java b/lib/src/main/java/io/ably/lib/realtime/Connection.java index 3ba28a434..8f0898550 100644 --- a/lib/src/main/java/io/ably/lib/realtime/Connection.java +++ b/lib/src/main/java/io/ably/lib/realtime/Connection.java @@ -1,6 +1,6 @@ package io.ably.lib.realtime; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.object.LiveObjectsPlugin; import io.ably.lib.realtime.ConnectionStateListener.ConnectionStateChange; import io.ably.lib.transport.ConnectionManager; import io.ably.lib.types.AblyException; diff --git a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java index c9985ef61..9b76f628a 100644 --- a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java +++ b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java @@ -14,7 +14,7 @@ import io.ably.lib.debug.DebugOptions; import io.ably.lib.debug.DebugOptions.RawProtocolListener; import io.ably.lib.http.HttpHelpers; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.object.LiveObjectsPlugin; import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.Channel; import io.ably.lib.realtime.ChannelState; diff --git a/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java b/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java index e813a21b7..ff86edd52 100644 --- a/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java +++ b/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java @@ -5,9 +5,8 @@ import java.util.Map; import com.google.gson.annotations.JsonAdapter; -import io.ably.lib.objects.ObjectsSerializer; -import io.ably.lib.objects.ObjectsHelper; -import io.ably.lib.objects.ObjectsJsonSerializer; +import io.ably.lib.object.serialization.ObjectJsonSerializer; +import io.ably.lib.object.serialization.ObjectSerializer; import org.jetbrains.annotations.Nullable; import org.msgpack.core.MessageFormat; import org.msgpack.core.MessagePacker; @@ -134,7 +133,7 @@ public ProtocolMessage(Action action, String channel) { * This is targeted and specific to the state field, so won't affect other fields */ @Nullable - @JsonAdapter(ObjectsJsonSerializer.class) + @JsonAdapter(ObjectJsonSerializer.class) public Object[] state; public @Nullable PublishResult[] res; @@ -162,7 +161,7 @@ void writeMsgpack(MessagePacker packer) throws IOException { if(params != null) ++fieldCount; if(channelSerial != null) ++fieldCount; if(annotations != null) ++fieldCount; - if(state != null && ObjectsHelper.getSerializer() != null) ++fieldCount; + if(state != null && ObjectSerializer.tryGet() != null) ++fieldCount; if(res != null) ++fieldCount; packer.packMapHeader(fieldCount); packer.packString("action"); @@ -204,7 +203,7 @@ void writeMsgpack(MessagePacker packer) throws IOException { AnnotationSerializer.writeMsgpackArray(annotations, packer); } if(state != null) { - ObjectsSerializer objectsSerializer = ObjectsHelper.getSerializer(); + ObjectSerializer objectsSerializer = ObjectSerializer.tryGet(); if (objectsSerializer != null) { packer.packString("state"); objectsSerializer.writeMsgpackArray(state, packer); @@ -279,7 +278,7 @@ ProtocolMessage readMsgpack(MessageUnpacker unpacker) throws IOException { annotations = AnnotationSerializer.readMsgpackArray(unpacker); break; case "state": - ObjectsSerializer objectsSerializer = ObjectsHelper.getSerializer(); + ObjectSerializer objectsSerializer = ObjectSerializer.tryGet(); if (objectsSerializer != null) { state = objectsSerializer.readMsgpackArray(unpacker); } else { diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java index 5ff53c6f4..8fa2a3bbc 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java @@ -2644,11 +2644,14 @@ public void channel_get_objects_throws_exception() throws AblyException { new ChannelWaiter(channel).waitFor(ChannelState.attached); assertEquals("Verify attached state reached", channel.state, ChannelState.attached); - AblyException exception = assertThrows(AblyException.class, channel::getObjects); + IllegalStateException exception = assertThrows(IllegalStateException.class, () -> channel.object.get()); assertNotNull(exception); - assertEquals(40019, exception.errorInfo.code); - assertEquals(400, exception.errorInfo.statusCode); - assertTrue(exception.errorInfo.message.contains("LiveObjects plugin hasn't been installed")); + assertTrue(exception.getMessage().contains("LiveObjects plugin hasn't been installed")); + + AblyException cause = (AblyException) exception.getCause(); + assertNotNull(cause); + assertEquals(40019, cause.errorInfo.code); + assertEquals(400, cause.errorInfo.statusCode); } } diff --git a/liveobjects/build.gradle.kts b/liveobjects/build.gradle.kts index 5b45ce92e..e8ef19da3 100644 --- a/liveobjects/build.gradle.kts +++ b/liveobjects/build.gradle.kts @@ -32,15 +32,15 @@ tasks.withType().configureEach { tasks.register("runLiveObjectUnitTests") { filter { - includeTestsMatching("io.ably.lib.objects.unit.*") + includeTestsMatching("io.ably.lib.object.unit.*") } } tasks.register("runLiveObjectIntegrationTests") { filter { - includeTestsMatching("io.ably.lib.objects.integration.*") + includeTestsMatching("io.ably.lib.object.integration.*") // Exclude the base integration test class - excludeTestsMatching("io.ably.lib.objects.integration.setup.IntegrationTest") + excludeTestsMatching("io.ably.lib.object.integration.setup.IntegrationTest") } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt index d86050ae0..e450c0d49 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt @@ -1,9 +1,18 @@ package io.ably.lib.`object` import io.ably.lib.`object`.adapter.AblyClientAdapter +import io.ably.lib.`object`.message.WireObjectMessage +import io.ably.lib.`object`.message.size import io.ably.lib.realtime.ChannelState -import io.ably.lib.types.ChannelMode +import io.ably.lib.realtime.CompletionListener +import io.ably.lib.realtime.ConnectionEvent +import io.ably.lib.realtime.ConnectionStateListener +import io.ably.lib.types.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.suspendCancellableCoroutine import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException /** * Wraps [onUnsubscribe] in a [Subscription] that runs the cleanup at most once; further @@ -53,7 +62,7 @@ internal fun AblyClientAdapter.throwIfInvalidWriteApiConfiguration(channelName: * * Spec: RTO2a, RTO2b */ -private fun AblyClientAdapter.getChannelModes(channelName: String): Array? { +internal fun AblyClientAdapter.getChannelModes(channelName: String): Array? { val channel = getChannel(channelName) channel.modes?.let { modes -> if (modes.isNotEmpty()) return modes } // RTO2a channel.options?.let { options -> if (options.hasModes()) return options.modes } // RTO2b @@ -86,3 +95,103 @@ private fun AblyClientAdapter.throwIfEchoMessagesDisabled() { ) } } + +internal fun AblyClientAdapter.throwIfUnpublishableState(channelName: String) { + if (!connectionManager.isActive) { + throw ablyException(connectionManager.stateErrorInfo) + } + throwIfInChannelState(channelName, arrayOf(ChannelState.failed, ChannelState.suspended)) +} + +internal val AblyClientAdapter.connectionManager get() = connection.connectionManager + +internal fun AblyClientAdapter.onGCGracePeriodUpdated(block : (Long?) -> Unit) : Subscription { + connectionManager.objectsGCGracePeriod?.let { block(it) } + // Return new objectsGCGracePeriod whenever connection state changes to connected + val listener: (_: ConnectionStateListener.ConnectionStateChange) -> Unit = { + block(connectionManager.objectsGCGracePeriod) + } + connection.on(ConnectionEvent.connected, listener) + return onceSubscription { connection.off(listener) } +} + +/** + * Spec: RTO15g + */ +internal suspend fun AblyClientAdapter.sendAsync(message: ProtocolMessage): PublishResult = suspendCancellableCoroutine { continuation -> + try { + connectionManager.send(message, clientOptions.queueMessages, object : Callback { + override fun onSuccess(result: PublishResult) { + continuation.resume(result) + } + + override fun onError(reason: ErrorInfo) { + continuation.resumeWithException(ablyException(reason)) + } + }) + } catch (e: Exception) { + continuation.resumeWithException(e) + } +} + +internal suspend fun AblyClientAdapter.attachAsync(channelName: String) = suspendCancellableCoroutine { continuation -> + try { + getChannel(channelName).attach(object : CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } + + override fun onError(reason: ErrorInfo) { + continuation.resumeWithException(ablyException(reason)) + } + }) + } catch (e: Exception) { + continuation.resumeWithException(e) + } +} + +/** + * Spec: RTO15d + */ +internal fun AblyClientAdapter.ensureMessageSizeWithinLimit(wireObjectMessages: Array) { + val maximumAllowedSize = connectionManager.maxMessageSize + val objectsTotalMessageSize = wireObjectMessages.sumOf { it.size() } + if (objectsTotalMessageSize > maximumAllowedSize) { + throw ablyException("ObjectMessages size $objectsTotalMessageSize exceeds maximum allowed size of $maximumAllowedSize bytes", + ObjectErrorCode.MaxMessageSizeExceeded) + } +} + +internal fun AblyClientAdapter.setChannelSerial(channelName: String, protocolMessage: ProtocolMessage) { + if (protocolMessage.action != ProtocolMessage.Action.`object`) return + val channelSerial = protocolMessage.channelSerial + if (channelSerial.isNullOrEmpty()) return + getChannel(channelName).properties.channelSerial = channelSerial +} + +internal suspend fun AblyClientAdapter.ensureAttached(channelName: String) { + val channel = getChannel(channelName) + when (val currentChannelStatus = channel.state) { + ChannelState.initialized -> attachAsync(channelName) + ChannelState.attached -> return + ChannelState.attaching -> { + val attachDeferred = CompletableDeferred() + getChannel(channelName).once { + when(it.current) { + ChannelState.attached -> attachDeferred.complete(Unit) + else -> { + val exception = ablyException("Channel $channelName is in invalid state: ${it.current}, " + + "error: ${it.reason}", ObjectErrorCode.ChannelStateError) + attachDeferred.completeExceptionally(exception) + } + } + } + if (channel.state == ChannelState.attached) { + attachDeferred.complete(Unit) + } + attachDeferred.await() + } + else -> + throw ablyException("Channel $channelName is in invalid state: $currentChannelStatus", ObjectErrorCode.ChannelStateError) + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/Utils.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/Utils.kt new file mode 100644 index 000000000..4509140eb --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/Utils.kt @@ -0,0 +1,60 @@ +package io.ably.lib.`object` + +import io.ably.lib.types.AblyException +import io.ably.lib.types.ErrorInfo +import java.nio.charset.StandardCharsets + +internal fun ablyException( + errorMessage: String, + errorCode: ObjectErrorCode, + statusCode: ObjectHttpStatusCode = ObjectHttpStatusCode.BadRequest, + cause: Throwable? = null, +): AblyException { + val errorInfo = createErrorInfo(errorMessage, errorCode, statusCode) + return createAblyException(errorInfo, cause) +} + +internal fun ablyException( + errorInfo: ErrorInfo, + cause: Throwable? = null, +): AblyException = createAblyException(errorInfo, cause) + +private fun createErrorInfo( + errorMessage: String, + errorCode: ObjectErrorCode, + statusCode: ObjectHttpStatusCode, +) = ErrorInfo(errorMessage, statusCode.code, errorCode.code) + +private fun createAblyException( + errorInfo: ErrorInfo, + cause: Throwable?, +) = cause?.let { AblyException.fromErrorInfo(it, errorInfo) } + ?: AblyException.fromErrorInfo(errorInfo) + +internal fun clientError(errorMessage: String) = ablyException(errorMessage, ObjectErrorCode.BadRequest, ObjectHttpStatusCode.BadRequest) + +internal fun serverError(errorMessage: String) = ablyException(errorMessage, ObjectErrorCode.InternalError, ObjectHttpStatusCode.InternalServerError) + +internal fun objectError(errorMessage: String, cause: Throwable? = null): AblyException { + return ablyException(errorMessage, ObjectErrorCode.InvalidObject, ObjectHttpStatusCode.InternalServerError, cause) +} + +internal fun invalidInputError(errorMessage: String, cause: Throwable? = null): AblyException { + return ablyException(errorMessage, ObjectErrorCode.InvalidInputParams, ObjectHttpStatusCode.InternalServerError, cause) +} + +/** + * Calculates the byte size of a string. + * For non-ASCII, the byte size can be 2–4x the character count. For ASCII, there is no difference. + * e.g. "Hello" has a byte size of 5, while "你" has a byte size of 3 and "😊" has a byte size of 4. + */ +internal val String.byteSize: Int + get() = this.toByteArray(StandardCharsets.UTF_8).size + +/** + * Generates a random nonce string for object creation. + */ +internal fun generateNonce(): String { + val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" // avoid calculation using range + return (1..16).map { chars.random() }.joinToString("") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt index b6f2f63f4..28b58d56e 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt @@ -1,12 +1,12 @@ package io.ably.lib.`object`.message -import com.google.gson.Gson import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.annotations.JsonAdapter import com.google.gson.annotations.SerializedName +import io.ably.lib.`object`.byteSize import io.ably.lib.`object`.serialization.WireObjectDataJsonSerializer -import java.nio.charset.StandardCharsets +import io.ably.lib.`object`.serialization.gson import java.util.Base64 /** @@ -156,18 +156,6 @@ internal data class WireObjectMessage( val siteCode: String? = null, // OM2i ) -// Gson instance for serializing the opaque `extras` field during size calculation. -// Kept file-local so this package has no dependency on `io.ably.lib.objects`. -private val gson = Gson() - -/** - * Calculates the byte size of a string. - * For non-ASCII, the byte size can be 2–4x the character count. For ASCII, there is no difference. - * e.g. "Hello" has a byte size of 5, while "你" has a byte size of 3 and "😊" has a byte size of 4. - */ -private val String.byteSize: Int - get() = this.toByteArray(StandardCharsets.UTF_8).size - /** * Calculates the size of an ObjectMessage in bytes. * Spec: OM3 diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt deleted file mode 100644 index 786eb594b..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt +++ /dev/null @@ -1,35 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.realtime.ChannelState -import io.ably.lib.types.ProtocolMessage -import java.util.concurrent.ConcurrentHashMap - -public class DefaultLiveObjectsPlugin(private val adapter: ObjectsAdapter) : LiveObjectsPlugin { - - private val objects = ConcurrentHashMap() - - override fun getInstance(channelName: String): RealtimeObjects { - return objects.getOrPut(channelName) { DefaultRealtimeObjects(channelName, adapter) } - } - - override fun handle(msg: ProtocolMessage) { - val channelName = msg.channel - objects[channelName]?.handle(msg) - } - - override fun handleStateChange(channelName: String, state: ChannelState, hasObjects: Boolean) { - objects[channelName]?.handleStateChange(state, hasObjects) - } - - override fun dispose(channelName: String) { - objects.remove(channelName) - ?.dispose(clientError("Channel has been released using channels.release()")) - } - - override fun dispose() { - objects.values.forEach { - it.dispose(clientError("AblyClient has been closed using client.close()")) - } - objects.clear() - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt deleted file mode 100644 index 617388fb6..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt +++ /dev/null @@ -1,372 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.objects.serialization.gson -import io.ably.lib.objects.state.ObjectsStateChange -import io.ably.lib.objects.state.ObjectsStateEvent -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.objects.type.counter.LiveCounter -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.type.map.LiveMap -import io.ably.lib.objects.type.map.LiveMapValue -import io.ably.lib.realtime.ChannelState -import io.ably.lib.types.AblyException -import io.ably.lib.types.ProtocolMessage -import io.ably.lib.types.PublishResult -import io.ably.lib.util.Clock -import io.ably.lib.util.Log -import io.ably.lib.util.SystemClock -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED -import kotlinx.coroutines.flow.MutableSharedFlow -import java.util.concurrent.CancellationException - -/** - * Default implementation of RealtimeObjects interface. - * Provides the core functionality for managing objects on a channel. - */ -internal class DefaultRealtimeObjects(internal val channelName: String, internal val adapter: ObjectsAdapter): RealtimeObjects { - private val tag = "DefaultRealtimeObjects" - /** - * @spec RTO3 - Objects pool storing all objects by object ID - */ - internal val objectsPool = ObjectsPool(this) - - internal var state = ObjectsState.Initialized - - /** - * Set of serials for operations applied locally upon ACK, awaiting deduplication of the server echo. - * @spec RTO7b, RTO7b1 - */ - internal val appliedOnAckSerials = mutableSetOf() - - /** - * @spec RTO4 - Used for handling object messages and object sync messages - */ - private val objectsManager = ObjectsManager(this) - - /** - * Coroutine scope for running sequential operations on a single thread, used to avoid concurrency issues. - */ - private val sequentialScope = - CoroutineScope(Dispatchers.Default.limitedParallelism(1) + CoroutineName(channelName) + SupervisorJob()) - - /** - * Event bus for handling incoming object messages sequentially. - * Processes messages inside [incomingObjectsHandler] job created using [sequentialScope]. - */ - private val objectsEventBus = MutableSharedFlow(extraBufferCapacity = UNLIMITED) - private val incomingObjectsHandler: Job - - /** - * Provides a channel-specific scope for safely executing asynchronous operations with callbacks. - */ - internal val asyncScope = ObjectsAsyncScope(channelName) - - init { - incomingObjectsHandler = initializeHandlerForIncomingObjectMessages() - } - - override fun getRoot(): LiveMap = runBlocking { getRootAsync() } - - override fun createMap(): LiveMap = createMap(mutableMapOf()) - - override fun createMap(entries: MutableMap): LiveMap = runBlocking { createMapAsync(entries) } - - override fun createCounter(): LiveCounter = createCounter(0) - - override fun createCounter(initialValue: Number): LiveCounter = runBlocking { createCounterAsync(initialValue) } - - override fun getRootAsync(callback: ObjectsCallback) { - asyncScope.launchWithCallback(callback) { getRootAsync() } - } - - override fun createMapAsync(callback: ObjectsCallback) = createMapAsync(mutableMapOf(), callback) - - override fun createMapAsync(entries: MutableMap, callback: ObjectsCallback) { - asyncScope.launchWithCallback(callback) { createMapAsync(entries) } - } - - override fun createCounterAsync(callback: ObjectsCallback) = createCounterAsync(0, callback) - - override fun createCounterAsync(initialValue: Number, callback: ObjectsCallback) { - asyncScope.launchWithCallback(callback) { createCounterAsync(initialValue) } - } - - override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription = - objectsManager.on(event, listener) - - override fun off(listener: ObjectsStateChange.Listener) = objectsManager.off(listener) - - override fun offAll() = objectsManager.offAll() - - private suspend fun getRootAsync(): LiveMap = withContext(sequentialScope.coroutineContext) { - adapter.throwIfInvalidAccessApiConfiguration(channelName) - adapter.ensureAttached(channelName) - objectsManager.ensureSynced(state) - objectsPool.get(ROOT_OBJECT_ID) as LiveMap - } - - private suspend fun createMapAsync(entries: MutableMap): LiveMap { - adapter.throwIfInvalidWriteApiConfiguration(channelName) // RTO11c, RTO11d, RTO11e - - if (entries.keys.any { it.isEmpty() }) { // RTO11f2 - throw invalidInputError("Map keys should not be empty") - } - - // RTO11f14 - Create initial value operation - val initialMapValue = DefaultLiveMap.initialValue(entries) - - // RTO11f15 - Create initial value JSON string - val initialValueJSONString = gson.toJson(initialMapValue) - - // RTO11f8 - Create object ID from initial value - val (objectId, nonce) = getObjectIdStringWithNonce(ObjectType.Map, initialValueJSONString) - - // Create ObjectMessage with the operation - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = objectId, - mapCreateWithObjectId = MapCreateWithObjectId( - nonce = nonce, - initialValue = initialValueJSONString, - derivedFrom = initialMapValue, - ), - ) - ) - - // RTO11i - publish and apply locally on ACK - publishAndApply(arrayOf(msg)) - - // RTO11h2 - Return existing object if found after apply - return objectsPool.get(objectId) as? LiveMap - ?: throw serverError("createMap: MAP_CREATE was not applied as expected; objectId=$objectId") // RTO11h3d - } - - private suspend fun createCounterAsync(initialValue: Number): LiveCounter { - adapter.throwIfInvalidWriteApiConfiguration(channelName) // RTO12c, RTO12d, RTO12e - - // Validate input parameter - if (initialValue.toDouble().isNaN() || initialValue.toDouble().isInfinite()) { - throw invalidInputError("Counter value should be a valid number") - } - - // RTO12f12 - val initialCounterValue = DefaultLiveCounter.initialValue(initialValue) - // RTO12f13 - Create initial value operation - val initialValueJSONString = gson.toJson(initialCounterValue) - - // RTO12f6- Create object ID from initial value - val (objectId, nonce) = getObjectIdStringWithNonce(ObjectType.Counter, initialValueJSONString) - - // Create ObjectMessage with the operation - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = objectId, - counterCreateWithObjectId = CounterCreateWithObjectId( - nonce = nonce, - initialValue = initialValueJSONString, - derivedFrom = initialCounterValue, - ), - ) - ) - - // RTO12i - publish and apply locally on ACK - publishAndApply(arrayOf(msg)) - - // RTO12h2 - Return existing object if found after apply - return objectsPool.get(objectId) as? LiveCounter - ?: throw serverError("createCounter: COUNTER_CREATE was not applied as expected; objectId=$objectId") // RTO12h3d - } - - /** - * Spec: RTO11f8, RTO12f6 - */ - private suspend fun getObjectIdStringWithNonce(objectType: ObjectType, initialValue: String): Pair { - val nonce = generateNonce() - val msTimestamp = ServerTime.getCurrentTime(adapter) // RTO16 - Get server time for nonce generation - return Pair(ObjectId.fromInitialValue(objectType, initialValue, nonce, msTimestamp).toString(), nonce) - } - - /** - * Spec: RTO15 - */ - internal suspend fun publish(objectMessages: Array): PublishResult { - // RTO15b, RTL6c - Ensure that the channel is in a valid state for publishing - adapter.throwIfUnpublishableState(channelName) - adapter.ensureMessageSizeWithinLimit(objectMessages) - // RTO15e - Must construct the ProtocolMessage as per RTO15e1, RTO15e2, RTO15e3 - val protocolMessage = ProtocolMessage(ProtocolMessage.Action.`object`, channelName) - protocolMessage.state = objectMessages - // RTO15f, RTO15g - Send the ProtocolMessage using the adapter and capture success/failure - return adapter.sendAsync(protocolMessage) // RTO15h - } - - /** - * Publishes the given object messages and, upon receiving the ACK, immediately applies them - * locally as synthetic inbound messages using the assigned serial and connection's siteCode. - * - * Spec: RTO20 - */ - internal suspend fun publishAndApply(objectMessages: Array) { - // RTO20b - publish, propagate failure - val publishResult = publish(objectMessages) - - // RTO20c - validate required info - val siteCode = adapter.connectionManager.siteCode - if (siteCode == null) { - Log.e(tag, "RTO20c1: siteCode not available; operations will be applied when echoed") - return - } - val serials = publishResult.serials - if (serials == null || serials.size != objectMessages.size) { - Log.e(tag, "RTO20c2: PublishResult.serials unavailable or wrong length; operations will be applied when echoed") - return - } - - // RTO20d - create synthetic inbound ObjectMessages - val syntheticMessages = mutableListOf() - objectMessages.forEachIndexed { i, msg -> - val serial = serials[i] - if (serial == null) { - Log.d(tag, "RTO20d1: serial null at index $i (conflated), skipping") - return@forEachIndexed - } - syntheticMessages.add(msg.copy(serial = serial, siteCode = siteCode)) // RTO20d2a, RTO20d2b, RTO20d3 - } - if (syntheticMessages.isEmpty()) return - - // RTO20e, RTO20f - dispatch to sequential scope for ordering - withContext(sequentialScope.coroutineContext) { - objectsManager.applyAckResult(syntheticMessages) // suspends if SYNCING (RTO20e), applies on SYNCED (RTO20f) - } - } - - /** - * Handles a ProtocolMessage containing proto action as `object` or `object_sync`. - * @spec RTL1 - Processes incoming object messages and object sync messages - */ - internal fun handle(protocolMessage: ProtocolMessage) { - // RTL15b - Set channel serial for OBJECT messages - adapter.setChannelSerial(channelName, protocolMessage) - - if (protocolMessage.state == null || protocolMessage.state.isEmpty()) { - Log.w(tag, "Received ProtocolMessage with null or empty objects, ignoring") - return - } - - objectsEventBus.tryEmit(protocolMessage) - } - - /** - * Initializes the handler for incoming object messages and object sync messages. - * Processes the messages sequentially to ensure thread safety and correct order of operations. - * - * @spec OM2 - Populates missing fields from parent protocol message - */ - private fun initializeHandlerForIncomingObjectMessages(): Job { - return sequentialScope.launch { - objectsEventBus.collect { protocolMessage -> - // OM2 - Populate missing fields from parent - val objects = protocolMessage.state.filterIsInstance() - .mapIndexed { index, objMsg -> - objMsg.copy( - connectionId = objMsg.connectionId ?: protocolMessage.connectionId, // OM2c - timestamp = objMsg.timestamp ?: protocolMessage.timestamp, // OM2e - id = objMsg.id ?: (protocolMessage.id + ':' + index) // OM2a - ) - } - - try { - when (protocolMessage.action) { - ProtocolMessage.Action.`object` -> objectsManager.handleObjectMessages(objects) - ProtocolMessage.Action.object_sync -> objectsManager.handleObjectSyncMessages( - objects, - protocolMessage.channelSerial - ) - else -> Log.w(tag, "Ignoring protocol message with unhandled action: ${protocolMessage.action}") - } - } catch (exception: Exception) { - // Skip current message if an error occurs, don't rethrow to avoid crashing the collector - Log.e(tag, "Error handling objects message with protocolMsg id ${protocolMessage.id}", exception) - } - } - } - } - - internal fun handleStateChange(state: ChannelState, hasObjects: Boolean) { - sequentialScope.launch { - when (state) { - ChannelState.attached -> { - Log.v(tag, "Objects.onAttached() channel=$channelName, hasObjects=$hasObjects") - - objectsManager.clearBufferedObjectOperations() // RTO4d - clear unconditionally on ATTACHED - - // RTO4a - val fromInitializedState = this@DefaultRealtimeObjects.state == ObjectsState.Initialized - if (hasObjects || fromInitializedState) { - // should always start a new sync sequence if we're in the initialized state, no matter the HAS_OBJECTS flag value. - // this guarantees we emit both "syncing" -> "synced" events in that order. - objectsManager.startNewSync(null) - } - - // RTO4b - if (!hasObjects) { - // if no HAS_OBJECTS flag received on attach, we can end sync sequence immediately and treat it as no objects on a channel. - // reset the objects pool to its initial state, and emit update events so subscribers to root object get notified about changes. - objectsPool.resetToInitialPool(true) // RTO4b1, RTO4b2 - objectsManager.clearSyncObjectsPool() // RTO4b3 - // RTO4b5 removed — buffer already cleared by RTO4d above - // defer the state change event until the next tick if we started a new sequence just now due to being in initialized state. - // this allows any event listeners to process the start of the new sequence event that was emitted earlier during this event loop. - objectsManager.endSync() // RTO4b4 - } - } - ChannelState.detached, - ChannelState.suspended, - ChannelState.failed -> { - val errorReason = try { - adapter.getChannel(channelName).reason - } catch (e: Exception) { - null - } - val error = ablyException( - "publishAndApply could not be applied locally: channel entered $state whilst waiting for objects sync", - ErrorCode.PublishAndApplyFailedDueToChannelState, - HttpStatusCode.BadRequest, - cause = errorReason?.let { AblyException.fromErrorInfo(it) } - ) - objectsManager.failBufferedAcks(error) // RTO20e1 - if (state != ChannelState.suspended) { - // do not emit data update events as the actual current state of Objects data is unknown when we're in these channel states - objectsPool.clearObjectsData(false) - objectsManager.clearSyncObjectsPool() - } - } - else -> { - // No action needed for other states - } - } - } - } - - // Dispose of any resources associated with this RealtimeObjects instance - fun dispose(cause: AblyException) { - val disposeReason = CancellationException().apply { initCause(cause) } - incomingObjectsHandler.cancel(disposeReason) // objectsEventBus automatically garbage collected when collector is cancelled - objectsPool.dispose() - objectsManager.dispose() - // Don't cancel sequentialScope (needed in getRoot method), just cancel ongoing coroutines - sequentialScope.coroutineContext.cancelChildren(disposeReason) - asyncScope.cancel(disposeReason) // cancel all ongoing callbacks - } -} - -/** - * Provides the default Clock instance for the DefaultRealtimeObjects. - * This Clock is derived from the system clock, utilizing the client options - * from the adapter configuration. - */ -internal val DefaultRealtimeObjects.clock get(): Clock = SystemClock.clockFrom(adapter.clientOptions) diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt deleted file mode 100644 index 1a8d1b8ad..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.ably.lib.objects - -internal enum class ErrorCode(public val code: Int) { - BadRequest(40_000), - InternalError(50_000), - MaxMessageSizeExceeded(40_009), - InvalidObject(92_000), - // LiveMap specific error codes - InvalidInputParams(40_003), - MapValueDataTypeUnsupported(40_013), - // Channel mode and state validation error codes - ChannelModeRequired(40_024), - ChannelStateError(90_001), - PublishAndApplyFailedDueToChannelState(92_008), -} - -internal enum class HttpStatusCode(public val code: Int) { - BadRequest(400), - InternalServerError(500), -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/Helpers.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/Helpers.kt deleted file mode 100644 index 683971510..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/Helpers.kt +++ /dev/null @@ -1,178 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.realtime.ChannelState -import io.ably.lib.realtime.CompletionListener -import io.ably.lib.types.Callback -import io.ably.lib.realtime.ConnectionEvent -import io.ably.lib.realtime.ConnectionStateListener -import io.ably.lib.types.ChannelMode -import io.ably.lib.types.ErrorInfo -import io.ably.lib.types.ProtocolMessage -import io.ably.lib.types.PublishResult -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -internal val ObjectsAdapter.connectionManager get() = connection.connectionManager - -/** - * Spec: RTO15g - */ -internal suspend fun ObjectsAdapter.sendAsync(message: ProtocolMessage): PublishResult = suspendCancellableCoroutine { continuation -> - try { - connectionManager.send(message, clientOptions.queueMessages, object : Callback { - override fun onSuccess(result: PublishResult) { - continuation.resume(result) - } - - override fun onError(reason: ErrorInfo) { - continuation.resumeWithException(ablyException(reason)) - } - }) - } catch (e: Exception) { - continuation.resumeWithException(e) - } -} - -internal suspend fun ObjectsAdapter.attachAsync(channelName: String) = suspendCancellableCoroutine { continuation -> - try { - getChannel(channelName).attach(object : CompletionListener { - override fun onSuccess() { - continuation.resume(Unit) - } - - override fun onError(reason: ErrorInfo) { - continuation.resumeWithException(ablyException(reason)) - } - }) - } catch (e: Exception) { - continuation.resumeWithException(e) - } -} - -internal fun ObjectsAdapter.onGCGracePeriodUpdated(block : (Long?) -> Unit) : ObjectsSubscription { - connectionManager.objectsGCGracePeriod?.let { block(it) } - // Return new objectsGCGracePeriod whenever connection state changes to connected - val listener: (_: ConnectionStateListener.ConnectionStateChange) -> Unit = { - block(connectionManager.objectsGCGracePeriod) - } - connection.on(ConnectionEvent.connected, listener) - return ObjectsSubscription { connection.off(listener) } -} - -/** - * Retrieves the channel modes for a specific channel. - * This method returns the modes that are set for the specified channel. - * - * @param channelName the name of the channel for which to retrieve the modes - * @return the array of channel modes for the specified channel, or null if the channel is not found - * Spec: RTO2a, RTO2b - */ -internal fun ObjectsAdapter.getChannelModes(channelName: String): Array? { - val channel = getChannel(channelName) - - // RTO2a - channel.modes is only populated on channel attachment, so use it only if it is set - channel.modes?.let { modes -> - if (modes.isNotEmpty()) { - return modes - } - } - - // RTO2b - otherwise as a best effort use user provided channel options - channel.options?.let { options -> - if (options.hasModes()) { - return options.modes - } - } - return null -} - -/** - * Spec: RTO15d - */ -internal fun ObjectsAdapter.ensureMessageSizeWithinLimit(objectMessages: Array) { - val maximumAllowedSize = connectionManager.maxMessageSize - val objectsTotalMessageSize = objectMessages.sumOf { it.size() } - if (objectsTotalMessageSize > maximumAllowedSize) { - throw ablyException("ObjectMessages size $objectsTotalMessageSize exceeds maximum allowed size of $maximumAllowedSize bytes", - ErrorCode.MaxMessageSizeExceeded) - } -} - -internal fun ObjectsAdapter.setChannelSerial(channelName: String, protocolMessage: ProtocolMessage) { - if (protocolMessage.action != ProtocolMessage.Action.`object`) return - val channelSerial = protocolMessage.channelSerial - if (channelSerial.isNullOrEmpty()) return - getChannel(channelName).properties.channelSerial = channelSerial -} - -internal suspend fun ObjectsAdapter.ensureAttached(channelName: String) { - val channel = getChannel(channelName) - when (val currentChannelStatus = channel.state) { - ChannelState.initialized -> attachAsync(channelName) - ChannelState.attached -> return - ChannelState.attaching -> { - val attachDeferred = CompletableDeferred() - getChannel(channelName).once { - when(it.current) { - ChannelState.attached -> attachDeferred.complete(Unit) - else -> { - val exception = ablyException("Channel $channelName is in invalid state: ${it.current}, " + - "error: ${it.reason}", ErrorCode.ChannelStateError) - attachDeferred.completeExceptionally(exception) - } - } - } - if (channel.state == ChannelState.attached) { - attachDeferred.complete(Unit) - } - attachDeferred.await() - } - else -> - throw ablyException("Channel $channelName is in invalid state: $currentChannelStatus", ErrorCode.ChannelStateError) - } -} - -// Spec: RTLO4b1, RTLO4b2 -internal fun ObjectsAdapter.throwIfInvalidAccessApiConfiguration(channelName: String) { - throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed)) - throwIfMissingChannelMode(channelName, ChannelMode.object_subscribe) -} - -internal fun ObjectsAdapter.throwIfInvalidWriteApiConfiguration(channelName: String) { - throwIfEchoMessagesDisabled() - throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed, ChannelState.suspended)) - throwIfMissingChannelMode(channelName, ChannelMode.object_publish) -} - -internal fun ObjectsAdapter.throwIfUnpublishableState(channelName: String) { - if (!connectionManager.isActive) { - throw ablyException(connectionManager.stateErrorInfo) - } - throwIfInChannelState(channelName, arrayOf(ChannelState.failed, ChannelState.suspended)) -} - -// Spec: RTO2 -private fun ObjectsAdapter.throwIfMissingChannelMode(channelName: String, channelMode: ChannelMode) { - val channelModes = getChannelModes(channelName) - if (channelModes == null || !channelModes.contains(channelMode)) { - // Spec: RTO2a2, RTO2b2 - throw ablyException("\"${channelMode.name}\" channel mode must be set for this operation", ErrorCode.ChannelModeRequired) - } -} - -private fun ObjectsAdapter.throwIfInChannelState(channelName: String, channelStates: Array) { - val currentState = getChannel(channelName).state - if (currentState == null || channelStates.contains(currentState)) { - throw ablyException("Channel is in invalid state: $currentState", ErrorCode.ChannelStateError) - } -} - -internal fun ObjectsAdapter.throwIfEchoMessagesDisabled() { - if (!clientOptions.echoMessages) { - throw clientError("\"echoMessages\" client option must be enabled for this operation") - } -} - - diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt deleted file mode 100644 index 64a040ddc..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt +++ /dev/null @@ -1,79 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.objects.type.ObjectType -import java.nio.charset.StandardCharsets -import java.security.MessageDigest -import java.util.Base64 - -internal class ObjectId private constructor( - internal val type: ObjectType, - private val hash: String, - private val timestampMs: Long -) { - /** - * Converts ObjectId to string representation. - * Spec: RTO6b1 - */ - override fun toString(): String { - return "${type.value}:$hash@$timestampMs" - } - - companion object { - - /** - * Spec: RTO14 - */ - internal fun fromInitialValue(objectType: ObjectType, initialValue: String, nonce: String, msTimeStamp: Long): ObjectId { - val valueForHash = "$initialValue:$nonce".toByteArray(StandardCharsets.UTF_8) - // RTO14b - Hash the initial value and nonce to create a unique identifier - val hashBytes = MessageDigest.getInstance("SHA-256").digest(valueForHash) - val urlSafeHash = Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes) - - return ObjectId(objectType, urlSafeHash, msTimeStamp) - } - - /** - * Creates ObjectId instance from hashed object id string. - */ - internal fun fromString(objectId: String): ObjectId { - if (objectId.isEmpty()) { - throw objectError("Invalid object id: $objectId") - } - - // Parse format: type:hash@msTimestamp - val parts = objectId.split(':') - if (parts.size != 2) { - throw objectError("Invalid object id: $objectId") - } - - val (typeStr, rest) = parts - - val type = when (typeStr) { - "map" -> ObjectType.Map - "counter" -> ObjectType.Counter - else -> throw objectError("Invalid object type in object id: $objectId") - } - - val hashAndTimestamp = rest.split('@') - if (hashAndTimestamp.size != 2) { - throw objectError("Invalid object id: $objectId") - } - - val hash = hashAndTimestamp[0] - - if (hash.isEmpty()) { - throw objectError("Invalid object id: $objectId") - } - - val msTimestampStr = hashAndTimestamp[1] - - val msTimestamp = try { - msTimestampStr.toLong() - } catch (e: NumberFormatException) { - throw objectError("Invalid object id: $objectId", e) - } - - return ObjectId(type, hash, msTimestamp) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt deleted file mode 100644 index 7f3e9b372..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt +++ /dev/null @@ -1,545 +0,0 @@ -package io.ably.lib.objects - -import com.google.gson.JsonElement -import com.google.gson.JsonObject - -import com.google.gson.annotations.JsonAdapter -import com.google.gson.annotations.SerializedName -import io.ably.lib.objects.serialization.ObjectDataJsonSerializer -import io.ably.lib.objects.serialization.gson -import java.util.Base64 - -/** - * An enum class representing the different actions that can be performed on an object. - * Spec: OOP2 - */ -internal enum class ObjectOperationAction(val code: Int) { - MapCreate(0), - MapSet(1), - MapRemove(2), - CounterCreate(3), - CounterInc(4), - ObjectDelete(5), - MapClear(6), - Unknown(-1); // code for unknown value during deserialization -} - -/** - * An enum class representing the conflict-resolution semantics used by a Map object. - * Spec: OMP2 - */ -internal enum class ObjectsMapSemantics(val code: Int) { - LWW(0), - Unknown(-1); // code for unknown value during deserialization -} - -/** - * An ObjectData represents a value in an object on a channel. - * Spec: OD1 - */ -@JsonAdapter(ObjectDataJsonSerializer::class) -internal data class ObjectData( - /** - * A reference to another object, used to support composable object structures. - * Spec: OD2a - */ - val objectId: String? = null, - - /** String value. Spec: OD2c */ - val string: String? = null, - - /** Numeric value. Spec: OD2c */ - val number: Double? = null, - - /** Boolean value. Spec: OD2c */ - val boolean: Boolean? = null, - - /** Binary value encoded as a base64 string. Spec: OD2c */ - val bytes: String? = null, - - /** JSON object or array value. Spec: OD2c */ - val json: JsonElement? = null, -) - -/** - * Payload for MAP_CREATE operation. - * Spec: MCR* - */ -internal data class MapCreate( - val semantics: ObjectsMapSemantics, // MCR2a - val entries: Map // MCR2b -) - -/** - * Payload for MAP_SET operation. - * Spec: MST* - */ -internal data class MapSet( - val key: String, // MST2a - val value: ObjectData // MST2b - REQUIRED -) - -/** - * Payload for MAP_REMOVE operation. - * Spec: MRM* - */ -internal data class MapRemove( - val key: String // MRM2a -) - -/** - * Payload for COUNTER_CREATE operation. - * Spec: CCR* - */ -internal data class CounterCreate( - val count: Double // CCR2a - REQUIRED -) - -/** - * Payload for COUNTER_INC operation. - * Spec: CIN* - */ -internal data class CounterInc( - val number: Double // CIN2a - REQUIRED -) - -/** - * Payload for OBJECT_DELETE operation. - * Spec: ODE* - * No fields - action is sufficient - */ -internal object ObjectDelete - -/** - * Payload for MAP_CLEAR operation. - * Spec: MCL* - * No fields - action is sufficient - */ -internal object MapClear - -/** - * Payload for MAP_CREATE_WITH_OBJECT_ID operation. - * Spec: MCRO* - */ -internal data class MapCreateWithObjectId( - val initialValue: String, // MCRO2a - val nonce: String, // MCRO2b - @Transient val derivedFrom: MapCreate? = null, -) - -/** - * Payload for COUNTER_CREATE_WITH_OBJECT_ID operation. - * Spec: CCRO* - */ -internal data class CounterCreateWithObjectId( - val initialValue: String, // CCRO2a - val nonce: String, // CCRO2b - @Transient val derivedFrom: CounterCreate? = null, -) - -/** - * A MapEntry represents the value at a given key in a Map object. - * Spec: ME1 - */ -internal data class ObjectsMapEntry( - /** - * Indicates whether the map entry has been removed. - * Spec: OME2a - */ - val tombstone: Boolean? = null, - - /** - * The serial value of the latest operation that was applied to the map entry. - * It is optional in a MAP_CREATE operation and might be missing, in which case the client should use a null value for it - * and treat it as the "earliest possible" serial for comparison purposes. - * Spec: OME2b - */ - val timeserial: String? = null, - - /** - * A timestamp from the [timeserial] field. Only present if [tombstone] is `true` - * Spec: OME2d - */ - val serialTimestamp: Long? = null, - - /** - * The data that represents the value of the map entry. - * Spec: OME2c - */ - val data: ObjectData? = null -) - -/** - * An ObjectMap object represents a map of key-value pairs. - * Spec: OMP1 - */ -internal data class ObjectsMap( - /** - * The conflict-resolution semantics used by the map object. - * Spec: OMP3a - */ - val semantics: ObjectsMapSemantics? = null, - - /** - * The map entries, indexed by key. - * Spec: OMP3b - */ - val entries: Map? = null, - - /** - * The serial value of the last MAP_CLEAR operation applied to the map. - * Spec: OMP3c - */ - val clearTimeserial: String? = null, -) - -/** - * An ObjectCounter object represents an incrementable and decrementable value - * Spec: OCN1 - */ -internal data class ObjectsCounter( - /** - * The value of the counter - * Spec: OCN2a - */ - val count: Double? = null -) - -/** - * An ObjectOperation describes an operation to be applied to an object on a channel. - * Spec: OOP1 - */ -internal data class ObjectOperation( - /** - * Defines the operation to be applied to the object. - * Spec: OOP3a - */ - val action: ObjectOperationAction, - - /** - * The object ID of the object on a channel to which the operation should be applied. - * Spec: OOP3b - */ - val objectId: String, - - /** - * Payload for MAP_CREATE operation. - * Spec: OOP3j - */ - val mapCreate: MapCreate? = null, - - /** - * Payload for MAP_SET operation. - * Spec: OOP3k - */ - val mapSet: MapSet? = null, - - /** - * Payload for MAP_REMOVE operation. - * Spec: OOP3l - */ - val mapRemove: MapRemove? = null, - - /** - * Payload for COUNTER_CREATE operation. - * Spec: OOP3m - */ - val counterCreate: CounterCreate? = null, - - /** - * Payload for COUNTER_INC operation. - * Spec: OOP3n - */ - val counterInc: CounterInc? = null, - - /** - * Payload for OBJECT_DELETE operation. - * Spec: OOP3o - */ - val objectDelete: ObjectDelete? = null, - - /** - * Payload for MAP_CREATE_WITH_OBJECT_ID operation. - * Spec: OOP3p - */ - val mapCreateWithObjectId: MapCreateWithObjectId? = null, - - /** - * Payload for COUNTER_CREATE_WITH_OBJECT_ID operation. - * Spec: OOP3q - */ - val counterCreateWithObjectId: CounterCreateWithObjectId? = null, - - /** - * Payload for MAP_CLEAR operation. - * Spec: OOP3r - */ - val mapClear: MapClear? = null, -) - -/** - * An ObjectState describes the instantaneous state of an object on a channel. - * Spec: OST1 - */ -internal data class ObjectState( - /** - * The identifier of the object. - * Spec: OST2a - */ - val objectId: String, - - /** - * A map of serials keyed by a {@link ObjectMessage.siteCode}, - * representing the last operations applied to this object - * Spec: OST2b - */ - val siteTimeserials: Map, - - /** - * True if the object has been tombstoned. - * Spec: OST2c - */ - val tombstone: Boolean, - - /** - * The operation that created the object. - * Can be missing if create operation for the object is not known at this point. - * Spec: OST2d - */ - val createOp: ObjectOperation? = null, - - /** - * The data that represents the result of applying all operations to a Map object - * excluding the initial value from the create operation if it is a Map object type. - * Spec: OST2e - */ - val map: ObjectsMap? = null, - - /** - * The data that represents the result of applying all operations to a Counter object - * excluding the initial value from the create operation if it is a Counter object type. - * Spec: OST2f - */ - val counter: ObjectsCounter? = null -) - -/** - * An @ObjectMessage@ represents an individual object message to be sent or received via the Ably Realtime service. - * Spec: OM1 - */ -internal data class ObjectMessage( - /** - * unique ID for this object message. This attribute is always populated for object messages received over REST. - * For object messages received over Realtime, if the object message does not contain an @id@, - * it should be set to @protocolMsgId:index@, where @protocolMsgId@ is the id of the @ProtocolMessage@ encapsulating it, - * and @index@ is the index of the object message inside the @state@ array of the @ProtocolMessage@ - * Spec: OM2a - */ - val id: String? = null, - - /** - * time in milliseconds since epoch. If an object message received from Ably does not contain a @timestamp@, - * it should be set to the @timestamp@ of the encapsulating @ProtocolMessage@ - * Spec: OM2e - */ - val timestamp: Long? = null, - - /** - * Spec: OM2b - */ - val clientId: String? = null, - - /** - * If an object message received from Ably does not contain a @connectionId@, - * it should be set to the @connectionId@ of the encapsulating @ProtocolMessage@ - * Spec: OM2c - */ - val connectionId: String? = null, - - /** - * JSON-encodable object, used to contain any arbitrary key value pairs which may also contain other primitive JSON types, - * JSON-encodable objects or JSON-encodable arrays. The @extras@ field is provided to contain message metadata and/or - * ancillary payloads in support of specific functionality. For 3.1 no specific functionality is specified for - * @extras@ in object messages. Unless otherwise specified, the client library should not attempt to do any filtering - * or validation of the @extras@ field itself, but should treat it opaquely, encoding it and passing it to realtime unaltered - * Spec: OM2d - */ - val extras: JsonObject? = null, - - /** - * Describes an operation to be applied to an object. - * Mutually exclusive with the `object` field. This field is only set on object messages if the `action` field of the - * `ProtocolMessage` encapsulating it is `OBJECT`. - * Spec: OM2f - */ - val operation: ObjectOperation? = null, - - /** - * Describes the instantaneous state of an object. - * Mutually exclusive with the `operation` field. This field is only set on object messages if the `action` field of - * the `ProtocolMessage` encapsulating it is `OBJECT_SYNC`. - * Spec: OM2g - */ - @SerializedName("object") - val objectState: ObjectState? = null, - - /** - * An opaque string that uniquely identifies this object message. - * Spec: OM2h - */ - val serial: String? = null, - - /** - * A timestamp from the [serial] field. - * Spec: OM2j - */ - val serialTimestamp: Long? = null, - - /** - * An opaque string used as a key to update the map of serial values on an object. - * Spec: OM2i - */ - val siteCode: String? = null -) - -/** - * Calculates the size of an ObjectMessage in bytes. - * Spec: OM3 - */ -internal fun ObjectMessage.size(): Int { - val clientIdSize = clientId?.byteSize ?: 0 // Spec: OM3f - val operationSize = operation?.size() ?: 0 // Spec: OM3b, OOP4 - val objectStateSize = objectState?.size() ?: 0 // Spec: OM3c, OST3 - val extrasSize = extras?.let { gson.toJson(it).length } ?: 0 // Spec: OM3d - - return clientIdSize + operationSize + objectStateSize + extrasSize -} - -/** - * Calculates the size of an ObjectOperation in bytes. - * Spec: OOP4 - */ -private fun ObjectOperation.size(): Int { - val mapCreateSize = mapCreate?.size() ?: mapCreateWithObjectId?.derivedFrom?.size() ?: 0 - val mapSetSize = mapSet?.size() ?: 0 - val mapRemoveSize = mapRemove?.size() ?: 0 - val counterCreateSize = counterCreate?.size() ?: counterCreateWithObjectId?.derivedFrom?.size() ?: 0 - val counterIncSize = counterInc?.size() ?: 0 - - return mapCreateSize + mapSetSize + mapRemoveSize + - counterCreateSize + counterIncSize -} - -/** - * Calculates the size of an ObjectState in bytes. - * Spec: OST3 - */ -private fun ObjectState.size(): Int { - val mapSize = map?.size() ?: 0 // Spec: OST3b, OMP4 - val counterSize = counter?.size() ?: 0 // Spec: OST3c, OCN3 - val createOpSize = createOp?.size() ?: 0 // Spec: OST3d, OOP4 - - return mapSize + counterSize + createOpSize -} - -/** - * Calculates the size of a MapCreate payload in bytes. - */ -private fun MapCreate.size(): Int { - return entries.entries.sumOf { it.key.byteSize + it.value.size() } -} - -/** - * Calculates the size of a MapSet payload in bytes. - */ -private fun MapSet.size(): Int { - return key.byteSize + value.size() -} - -/** - * Calculates the size of a MapRemove payload in bytes. - */ -private fun MapRemove.size(): Int { - return key.byteSize -} - -/** - * Calculates the size of a CounterCreate payload in bytes. - */ -private fun CounterCreate.size(): Int { - return 8 // Double is 8 bytes -} - -/** - * Calculates the size of a CounterInc payload in bytes. - */ -private fun CounterInc.size(): Int { - return 8 // Double is 8 bytes -} - -/** - * Calculates the size of a MapCreateWithObjectId payload in bytes. - */ -private fun MapCreateWithObjectId.size(): Int { - return initialValue.byteSize + nonce.byteSize -} - -/** - * Calculates the size of a CounterCreateWithObjectId payload in bytes. - */ -private fun CounterCreateWithObjectId.size(): Int { - return initialValue.byteSize + nonce.byteSize -} - -/** - * Calculates the size of an ObjectMap in bytes. - * Spec: OMP4 - */ -private fun ObjectsMap.size(): Int { - // Calculate the size of all map entries in the map property - val entriesSize = entries?.entries?.sumOf { - it.key.length + it.value.size() // // Spec: OMP4a1, OMP4a2 - } ?: 0 - - return entriesSize -} - -/** - * Calculates the size of an ObjectCounter in bytes. - * Spec: OCN3 - */ -private fun ObjectsCounter.size(): Int { - // Size is 8 if count is a number, 0 if count is null or omitted - return if (count != null) 8 else 0 -} - -/** - * Calculates the size of a MapEntry in bytes. - * Spec: OME3 - */ -private fun ObjectsMapEntry.size(): Int { - // The size is equal to the size of the data property, calculated per "OD3" - return data?.size() ?: 0 -} - -/** - * Calculates the size of an ObjectData in bytes. - * Spec: OD3 - */ -private fun ObjectData.size(): Int { - string?.let { return it.byteSize } // Spec: OD3e - number?.let { return 8 } // Spec: OD3d - boolean?.let { return 1 } // Spec: OD3b - bytes?.let { return Base64.getDecoder().decode(it).size } // Spec: OD3c - json?.let { return it.toString().byteSize } // Spec: OD3e - return 0 -} - -internal fun ObjectData?.isInvalid(): Boolean { - return this?.objectId.isNullOrEmpty() && - this?.string == null && - this?.number == null && - this?.boolean == null && - this?.bytes == null && - this?.json == null -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt deleted file mode 100644 index 9c669f033..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt +++ /dev/null @@ -1,328 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.ObjectUpdate -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.types.AblyException -import io.ably.lib.util.Log -import kotlinx.coroutines.CompletableDeferred - -/** - * @spec RTO5 - Processes OBJECT and OBJECT_SYNC messages during sync sequences - * @spec RTO6 - Creates zero-value objects when needed - */ -internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObjects): ObjectsStateCoordinator() { - private val tag = "ObjectsManager" - /** - * @spec RTO5 - Sync objects pool for collecting sync messages - */ - private val syncObjectsPool = mutableMapOf() - private var currentSyncId: String? = null - /** - * @spec RTO7 - Buffered object operations during sync - */ - private val bufferedObjectOperations = mutableListOf() // RTO7a - private var syncCompletionWaiter: CompletableDeferred? = null - - /** - * Handles object messages (non-sync messages). - * - * @spec RTO8 - Buffers messages if not synced, applies immediately if synced - */ - internal fun handleObjectMessages(objectMessages: List) { - if (realtimeObjects.state != ObjectsState.Synced) { - // RTO7 - The client receives object messages in realtime over the channel concurrently with the sync sequence. - // Some of the incoming object messages may have already been applied to the objects described in - // the sync sequence, but others may not; therefore we must buffer these messages so that we can apply - // them to the objects once the sync is complete. - Log.v(tag, "Buffering ${objectMessages.size} object messages, state: ${realtimeObjects.state}") - bufferedObjectOperations.addAll(objectMessages) // RTO8a - return - } - - // Apply messages immediately if synced - applyObjectMessages(objectMessages, ObjectsOperationSource.CHANNEL) // RTO8b - } - - /** - * Handles object sync messages. - * - * @spec RTO5 - Parses sync channel serial and manages sync sequences - */ - internal fun handleObjectSyncMessages(objectMessages: List, syncChannelSerial: String?) { - val syncTracker = ObjectsSyncTracker(syncChannelSerial) - val isNewSync = syncTracker.hasSyncStarted(currentSyncId) - if (isNewSync) { - // RTO5a2 - new sync sequence started - startNewSync(syncTracker.syncId) - } - - // RTO5a3 - continue current sync sequence - applyObjectSyncMessages(objectMessages) // RTO5f - - // RTO5a4 - if this is the last (or only) message in a sequence of sync updates, end the sync - if (syncTracker.hasSyncEnded()) { - // defer the state change event until the next tick if this was a new sync sequence - // to allow any event listeners to process the start of the new sequence event that was emitted earlier during this event loop. - endSync() - } - } - - /** - * Starts a new sync sequence. - * - * @spec RTO5 - Sync sequence initialization - */ - internal fun startNewSync(syncId: String?) { - Log.v(tag, "Starting new sync sequence: syncId=$syncId") - - syncObjectsPool.clear() // RTO5a2a - currentSyncId = syncId - syncCompletionWaiter = CompletableDeferred() - stateChange(ObjectsState.Syncing) - } - - /** - * Ends the current sync sequence. - * - * @spec RTO5c - Applies sync data and buffered operations - */ - internal fun endSync() { - Log.v(tag, "Ending sync sequence") - applySync() // RTO5c1/2/7 - applyObjectMessages(bufferedObjectOperations, ObjectsOperationSource.CHANNEL) // RTO5c6 - bufferedObjectOperations.clear() // RTO5c5 - syncObjectsPool.clear() // RTO5c4 - currentSyncId = null // RTO5c3 - realtimeObjects.appliedOnAckSerials.clear() // RTO5c9 - stateChange(ObjectsState.Synced) // RTO5c8 - syncCompletionWaiter?.complete(Unit) - syncCompletionWaiter = null - } - - /** - * Called from publishAndApply (via withContext sequentialScope). - * If SYNCED: apply immediately with LOCAL source. - * If not SYNCED: suspend until endSync transitions to SYNCED (RTO20e), then apply. - */ - internal suspend fun applyAckResult(messages: List) { - if (realtimeObjects.state != ObjectsState.Synced) { - if (syncCompletionWaiter == null) syncCompletionWaiter = CompletableDeferred() - syncCompletionWaiter?.await() // suspends; resumes after endSync transitions to SYNCED (RTO20e1) - } - applyObjectMessages(messages, ObjectsOperationSource.LOCAL) // RTO20f - } - - /** - * Fails all pending apply waiters. - * Called when the channel enters DETACHED/SUSPENDED/FAILED (RTO20e1). - */ - internal fun failBufferedAcks(error: AblyException) { - syncCompletionWaiter?.completeExceptionally(error) - syncCompletionWaiter = null - } - - /** - * Clears the sync objects pool. - * Used by DefaultRealtimeObjects.handleStateChange. - */ - internal fun clearSyncObjectsPool() { - syncObjectsPool.clear() - } - - /** - * Clears the buffered object operations. - * Used by DefaultRealtimeObjects.handleStateChange. - */ - internal fun clearBufferedObjectOperations() { - bufferedObjectOperations.clear() - } - - /** - * Applies sync data to objects pool. - * - * @spec RTO5c - Processes sync data and updates objects pool - */ - private fun applySync() { - if (syncObjectsPool.isEmpty()) { - return - } - - val receivedObjectIds = mutableSetOf() - // RTO5c1a2 - List to collect updates for existing objects - val existingObjectUpdates = mutableListOf>() - - // RTO5c1 - for ((objectId, objectMessage) in syncObjectsPool) { - val objectState = objectMessage.objectState as ObjectState // we have non-null objectState here due to RTO5f - receivedObjectIds.add(objectId) - val existingObject = realtimeObjects.objectsPool.get(objectId) - - // RTO5c1a - if (existingObject != null) { - // Update existing object - val update = existingObject.applyObjectSync(objectMessage) // RTO5c1a1 - existingObjectUpdates.add(Pair(existingObject, update)) - } else { // RTO5c1b - // RTO5c1b1, RTO5c1b1a, RTO5c1b1b - Create new object and add it to the pool - val newObject = createObjectFromState(objectState) ?: continue // RTO5c1b1c - skip unsupported - newObject.applyObjectSync(objectMessage) - realtimeObjects.objectsPool.set(objectId, newObject) - } - } - - // RTO5c2 - need to remove realtimeObject instances from the ObjectsPool for which objectIds were not received during the sync sequence - realtimeObjects.objectsPool.deleteExtraObjectIds(receivedObjectIds) - - // RTO5c7 - call subscription callbacks for all updated existing objects - existingObjectUpdates.forEach { (obj, update) -> - obj.notifyUpdated(update) - } - } - - /** - * Applies object messages to objects. - * - * @spec RTO9 - Creates zero-value objects if they don't exist - */ - private fun applyObjectMessages( - objectMessages: List, - source: ObjectsOperationSource = ObjectsOperationSource.CHANNEL, - ) { - // RTO9a - for (objectMessage in objectMessages) { - if (objectMessage.operation == null) { - // RTO9a1 - Log.w(tag, "Object message received without operation field, skipping message: ${objectMessage.id}") - continue - } - - val objectOperation: ObjectOperation = objectMessage.operation // RTO9a2 - if (objectOperation.action == ObjectOperationAction.Unknown) { - // RTO9a2b - object operation action is unknown, skip the message - Log.w(tag, "Object operation action is unknown, skipping message: ${objectMessage.id}") - continue - } - - // RTO9a3 - skip operations already applied on ACK (discard without taking any further action). - // This check comes before zero-value object creation (RTO9a2a1) so that no zero-value object is - // created for an objectId not yet in the pool when the echo is being discarded. - // Note: siteTimeserials is NOT updated here intentionally — updating it to the echo's serial would - // incorrectly reject older-but-unprocessed operations from the same site that arrive after the echo. - if (objectMessage.serial != null && - realtimeObjects.appliedOnAckSerials.contains(objectMessage.serial)) { - Log.d(tag, "RTO9a3: serial ${objectMessage.serial} already applied on ACK; discarding echo") - realtimeObjects.appliedOnAckSerials.remove(objectMessage.serial) - continue // discard without taking any further action - } - - // RTO9a2a - we can receive an op for an object id we don't have yet in the pool. instead of buffering such operations, - // we can create a zero-value object for the provided object id and apply the operation to that zero-value object. - // this also means that all objects are capable of applying the corresponding *_CREATE ops on themselves, - // since they need to be able to eventually initialize themselves from that *_CREATE op. - // so to simplify operations handling, we always try to create a zero-value object in the pool first, - // and then we can always apply the operation on the existing object in the pool. - val obj = realtimeObjects.objectsPool.createZeroValueObjectIfNotExists(objectOperation.objectId) // RTO9a2a1 - val applied = obj.applyObject(objectMessage, source) // RTO9a2a2, RTO9a2a3 - if (source == ObjectsOperationSource.LOCAL && applied && objectMessage.serial != null) { - realtimeObjects.appliedOnAckSerials.add(objectMessage.serial) // RTO9a2a4 - } - } - } - - /** - * Applies sync messages to sync data pool, merging partial sync messages for the same objectId. - * - * @spec RTO5f - Collects and merges object states during sync sequence - */ - private fun applyObjectSyncMessages(objectMessages: List) { - for (objectMessage in objectMessages) { - if (objectMessage.objectState == null) { - Log.w(tag, "Object message received during OBJECT_SYNC without object field, skipping message: ${objectMessage.id}") - continue - } - - val objectState: ObjectState = objectMessage.objectState - val objectId = objectState.objectId - val existingEntry = syncObjectsPool[objectId] - - if (existingEntry == null) { - // RTO5f1 - objectId not in pool, store directly - if (objectState.counter != null || objectState.map != null) { - syncObjectsPool[objectId] = objectMessage - } else { - // RTO5c1b1c - object state must contain either counter or map data - Log.w(tag, "Object state received without counter or map data, skipping message: ${objectMessage.id}") - } - continue - } - - // RTO5f2 - objectId already in pool; this is a partial sync message, merge based on type - when { - objectState.map != null -> { - // RTO5f2a - map object: merge entries - if (objectState.tombstone) { - // RTO5f2a1 - tombstone: replace pool entry entirely - syncObjectsPool[objectId] = objectMessage - } else { - // RTO5f2a2 - merge map entries; server guarantees no duplicate keys across partials - val existingState = existingEntry.objectState!! // non-null for existing entry - val mergedEntries = existingState.map?.entries.orEmpty() + objectState.map.entries.orEmpty() - val mergedMap = (existingState.map ?: ObjectsMap()).copy(entries = mergedEntries) - val mergedState = existingState.copy(map = mergedMap) - syncObjectsPool[objectId] = existingEntry.copy(objectState = mergedState) - } - } - objectState.counter != null -> { - // RTO5f2b - counter objects must never be split across messages - Log.e(tag, "Received partial sync message for a counter object, skipping: ${objectMessage.id}") - } - else -> { - // RTO5f2c - unsupported type, log warning and skip - Log.w(tag, "Received partial sync message for an unsupported object type, skipping: ${objectMessage.id}") - } - } - } - } - - /** - * Creates an object from object state. - * - * @spec RTO5c1b - Creates objects from object state based on type - */ - private fun createObjectFromState(objectState: ObjectState): BaseRealtimeObject? { - return when { - objectState.counter != null -> DefaultLiveCounter.zeroValue(objectState.objectId, realtimeObjects) // RTO5c1b1a - objectState.map != null -> DefaultLiveMap.zeroValue(objectState.objectId, realtimeObjects) // RTO5c1b1b - else -> { - // RTO5c1b1c - unsupported object type, skip gracefully - Log.w(tag, "Received unsupported object state during OBJECT_SYNC (no counter or map), skipping objectId: ${objectState.objectId}") - null - } - } - } - - /** - * Changes the state and emits events. - * - * @spec RTO2 - Emits state change events for syncing and synced states - */ - private fun stateChange(newState: ObjectsState) { - if (realtimeObjects.state == newState) { - return - } - Log.v(tag, "Objects state changed to: $newState from ${realtimeObjects.state}") - realtimeObjects.state = newState - - // deferEvent not needed since objectsStateChanged processes events in a sequential coroutine scope - objectsStateChanged(newState) - } - - internal fun dispose() { - syncCompletionWaiter?.cancel() - syncObjectsPool.clear() - bufferedObjectOperations.clear() - disposeObjectsStateListeners() - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsOperationSource.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsOperationSource.kt deleted file mode 100644 index e850d31b8..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsOperationSource.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.ably.lib.objects - -/** @spec RTO22 */ -internal enum class ObjectsOperationSource { - LOCAL, // RTO22a - applied upon receipt of ACK - CHANNEL // RTO22b - received over a Realtime channel -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt deleted file mode 100644 index 224cd606f..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt +++ /dev/null @@ -1,172 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.util.Log -import kotlinx.coroutines.* -import java.util.concurrent.ConcurrentHashMap - -/** - * Constants for ObjectsPool configuration - */ -internal object ObjectsPoolDefaults { - const val GC_INTERVAL_MS = 1000L * 60 * 5 // 5 minutes - /** - * The SDK will attempt to use the `objectsGCGracePeriod` value provided by the server in the `connectionDetails` - * object of the `CONNECTED` event. - * If the server does not provide this value, the SDK will fall back to this default value. - * Must be > 2 minutes to ensure we keep tombstones long enough to avoid the possibility of receiving an operation - * with an earlier serial that would not have been applied if the tombstone still existed. - * - * Applies both for map entries tombstones and object tombstones. - */ - const val GC_GRACE_PERIOD_MS = 1000L * 60 * 60 * 24 // 24 hours -} - -/** - * Root object ID constant - */ -internal const val ROOT_OBJECT_ID = "root" - -/** - * ObjectsPool manages a pool of objects for a channel. - * - * @spec RTO3 - Maintains an objects pool for all objects on the channel - */ -internal class ObjectsPool( - private val realtimeObjects: DefaultRealtimeObjects -) { - private val tag = "ObjectsPool" - - /** - * ConcurrentHashMap for thread-safe access from public APIs in LiveMap and LiveCounter. - * @spec RTO3a - Pool storing all ably objects by object ID - */ - private val pool = ConcurrentHashMap() - - /** - * Coroutine scope for garbage collection - */ - private val gcScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - private var gcJob: Job // Job for the garbage collection coroutine - - @Volatile private var gcGracePeriod = ObjectsPoolDefaults.GC_GRACE_PERIOD_MS - private var gcPeriodSubscription: ObjectsSubscription - - init { - // RTO3b - Initialize pool with root object - pool[ROOT_OBJECT_ID] = DefaultLiveMap.zeroValue(ROOT_OBJECT_ID, realtimeObjects) - // Start garbage collection coroutine with server-provided grace period if available - gcPeriodSubscription = realtimeObjects.adapter.onGCGracePeriodUpdated { period -> - period?.let { - gcGracePeriod = it - Log.i(tag, "Using objectsGCGracePeriod from server: $gcGracePeriod ms") - } ?: Log.i(tag, "Server did not provide objectsGCGracePeriod, using default: $gcGracePeriod ms") - } - gcJob = startGCJob() - } - - /** - * Gets an object from the pool by object ID. - */ - internal fun get(objectId: String): BaseRealtimeObject? { - return pool[objectId] - } - - /** - * Sets a realtime object in the pool. - */ - internal fun set(objectId: String, realtimeObject: BaseRealtimeObject) { - pool[objectId] = realtimeObject - } - - /** - * Removes all objects but root from the pool and clears the data for root. - * Does not create a new root object, so the reference to the root object remains the same. - */ - internal fun resetToInitialPool(emitUpdateEvents: Boolean) { - pool.entries.removeIf { (key, _) -> key != ROOT_OBJECT_ID } // only keep the root object - clearObjectsData(emitUpdateEvents) // RTO4b2a - clear the root object and emit update events - } - - - /** - * Deletes objects from the pool for which object ids are not found in the provided array of ids. - * Spec: RTO5c2 - */ - internal fun deleteExtraObjectIds(objectIds: MutableSet) { - pool.entries.removeIf { (key, _) -> key !in objectIds && key != ROOT_OBJECT_ID } // RTO5c2a - Keep root object - } - - /** - * Clears the data stored for all objects in the pool. - */ - internal fun clearObjectsData(emitUpdateEvents: Boolean) { - for (obj in pool.values) { - val update = obj.clearData() - if (emitUpdateEvents) obj.notifyUpdated(update) - } - } - - /** - * Creates a zero-value object if it doesn't exist in the pool. - * - * @spec RTO6 - Creates zero-value objects when needed - */ - internal fun createZeroValueObjectIfNotExists(objectId: String): BaseRealtimeObject { - val existingObject = get(objectId) - if (existingObject != null) { - return existingObject // RTO6a - } - - val parsedObjectId = ObjectId.fromString(objectId) // RTO6b - return when (parsedObjectId.type) { - ObjectType.Map -> DefaultLiveMap.zeroValue(objectId, realtimeObjects) // RTO6b2 - ObjectType.Counter -> DefaultLiveCounter.zeroValue(objectId, realtimeObjects) // RTO6b3 - }.apply { - set(objectId, this) // RTO6b4 - Add the zero-value object to the pool - } - } - - /** - * Garbage collection interval handler. - */ - private fun onGCInterval() { - pool.entries.removeIf { (_, obj) -> - if (obj.isEligibleForGc(gcGracePeriod)) { true } // Remove from pool - else { - obj.onGCInterval(gcGracePeriod) - false // Keep in pool - } - } - } - - /** - * Starts the garbage collection coroutine. - */ - private fun startGCJob() : Job { - return gcScope.launch { - while (isActive) { - try { - onGCInterval() - } catch (e: Exception) { - Log.e(tag, "Error during garbage collection", e) - } - delay(ObjectsPoolDefaults.GC_INTERVAL_MS) - } - } - } - - /** - * Disposes of the ObjectsPool, cleaning up resources. - * Should be called when the pool is no longer needed. - */ - fun dispose() { - gcPeriodSubscription.unsubscribe() - gcJob.cancel() - gcScope.cancel() - pool.clear() - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt deleted file mode 100644 index cdd742ec0..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt +++ /dev/null @@ -1,108 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.objects.state.ObjectsStateChange -import io.ably.lib.objects.state.ObjectsStateEvent -import io.ably.lib.util.EventEmitter -import io.ably.lib.util.Log -import kotlinx.coroutines.* - -/** - * @spec RTO2 - enum representing objects state - */ -internal enum class ObjectsState { - Initialized, - Syncing, - Synced -} - -/** - * Maps internal ObjectsState values to their corresponding public ObjectsStateEvent values. - * Used to determine which events should be emitted when state changes occur. - * INITIALIZED maps to null (no event), while SYNCING and SYNCED map to their respective events. - */ -private val objectsStateToEventMap = mapOf( - ObjectsState.Initialized to null, - ObjectsState.Syncing to ObjectsStateEvent.SYNCING, - ObjectsState.Synced to ObjectsStateEvent.SYNCED -) - -/** - * An interface for managing and communicating changes in the synchronization state of objects. - * - * Implementations should ensure thread-safe event emission and proper synchronization - * between state change notifications. - */ -internal interface HandlesObjectsStateChange { - /** - * Handles changes in the state of objects by notifying all registered listeners. - * Implementations should ensure thread-safe event emission to both internal and public listeners. - * Makes sure every event is processed in the order they were received. - * @param newState The new state of the objects, SYNCING or SYNCED. - */ - fun objectsStateChanged(newState: ObjectsState) - - /** - * Suspends the current coroutine until objects are synchronized. - * Returns immediately if state is already SYNCED, otherwise waits for the SYNCED event. - * - * @param currentState The current state of objects to determine if waiting is necessary - */ - suspend fun ensureSynced(currentState: ObjectsState) - - /** - * Disposes all registered state change listeners and cancels any pending operations. - * Should be called when the associated RealtimeObjects instance is no longer needed. - */ - fun disposeObjectsStateListeners() -} - - -internal abstract class ObjectsStateCoordinator : ObjectsStateChange, HandlesObjectsStateChange { - private val tag = "ObjectsStateCoordinator" - private val internalObjectStateEmitter = ObjectsStateEmitter() - // related to RTC10, should have a separate EventEmitter for users of the library - private val externalObjectStateEmitter = ObjectsStateEmitter() - - override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription { - externalObjectStateEmitter.on(event, listener) - return ObjectsSubscription { - externalObjectStateEmitter.off(event, listener) - } - } - - override fun off(listener: ObjectsStateChange.Listener) = externalObjectStateEmitter.off(listener) - - override fun offAll() = externalObjectStateEmitter.off() - - override fun objectsStateChanged(newState: ObjectsState) { - objectsStateToEventMap[newState]?.let { objectsStateEvent -> - internalObjectStateEmitter.emit(objectsStateEvent) - externalObjectStateEmitter.emit(objectsStateEvent) - } - } - - override suspend fun ensureSynced(currentState: ObjectsState) { - if (currentState != ObjectsState.Synced) { - val deferred = CompletableDeferred() - internalObjectStateEmitter.once(ObjectsStateEvent.SYNCED) { - Log.v(tag, "Objects state changed to SYNCED, resuming ensureSynced") - deferred.complete(Unit) - } - deferred.await() - } - } - - override fun disposeObjectsStateListeners() = offAll() -} - -private class ObjectsStateEmitter : EventEmitter() { - private val tag = "ObjectsStateEmitter" - override fun apply(listener: ObjectsStateChange.Listener?, event: ObjectsStateEvent?, vararg args: Any?) { - try { - event?.let { listener?.onStateChanged(it) } - ?: Log.w(tag, "Null event passed to ObjectsStateChange Listener callback") - } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing listener callback for event: $event", t) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt deleted file mode 100644 index 5c2a193d5..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt +++ /dev/null @@ -1,63 +0,0 @@ -package io.ably.lib.objects - -/** - * @spec RTO5 - SyncTracker class for tracking objects sync status - */ -internal class ObjectsSyncTracker(syncChannelSerial: String?) { - private val syncSerial: String? = syncChannelSerial - internal val syncId: String? - internal val syncCursor: String? - - init { - val parsed = parseSyncChannelSerial(syncChannelSerial) - syncId = parsed.first - syncCursor = parsed.second - } - - /** - * Checks if a new sync sequence has started. - * - * @param prevSyncId The previously stored sync ID - * @return true if a new sync sequence has started, false otherwise - * - * Spec: RTO5a5, RTO5a2 - */ - internal fun hasSyncStarted(prevSyncId: String?): Boolean { - return syncSerial.isNullOrEmpty() || prevSyncId != syncId - } - - /** - * Checks if the current sync sequence has ended. - * - * @return true if the sync sequence has ended, false otherwise - * - * Spec: RTO5a5, RTO5a4 - */ - internal fun hasSyncEnded(): Boolean { - return syncSerial.isNullOrEmpty() || syncCursor.isNullOrEmpty() - } - - companion object { - /** - * Parses sync channel serial to extract syncId and syncCursor. - * - * @param syncChannelSerial The sync channel serial to parse - * @return Pair of syncId and syncCursor, both null if parsing fails - */ - private fun parseSyncChannelSerial(syncChannelSerial: String?): Pair { - if (syncChannelSerial.isNullOrEmpty()) { - return Pair(null, null) - } - - // RTO5a1 - syncChannelSerial is a two-part identifier: : - val match = Regex("^([\\w-]+):(.*)$").find(syncChannelSerial) - return if (match != null) { - val syncId = match.groupValues[1] - val syncCursor = match.groupValues[2] - Pair(syncId, syncCursor) - } else { - Pair(null, null) - } - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt deleted file mode 100644 index 09b8b1c14..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt +++ /dev/null @@ -1,37 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.types.AblyException -import io.ably.lib.util.SystemClock -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlin.concurrent.Volatile - -/** - * ServerTime is a utility object that provides the current server time - * Spec: RTO16 - */ -internal object ServerTime { - @Volatile - private var serverTimeOffset: Long? = null - private val mutex = Mutex() - - /** - * Spec: RTO16a - */ - @Throws(AblyException::class) - internal suspend fun getCurrentTime(adapter: ObjectsAdapter): Long { - val clock = SystemClock.clockFrom(adapter.clientOptions) - if (serverTimeOffset == null) { - mutex.withLock { - if (serverTimeOffset == null) { // Double-checked locking to ensure thread safety - val serverTime: Long = withContext(Dispatchers.IO) { adapter.time } - serverTimeOffset = serverTime - clock.currentTimeMillis() - return serverTime - } - } - } - return clock.currentTimeMillis() + serverTimeOffset!! - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/Utils.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/Utils.kt deleted file mode 100644 index 3e136163e..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/Utils.kt +++ /dev/null @@ -1,117 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.types.AblyException -import io.ably.lib.types.ErrorInfo -import io.ably.lib.util.Log -import kotlinx.coroutines.* -import java.nio.charset.StandardCharsets -import java.util.concurrent.CancellationException - -internal fun ablyException( - errorMessage: String, - errorCode: ErrorCode, - statusCode: HttpStatusCode = HttpStatusCode.BadRequest, - cause: Throwable? = null, -): AblyException { - val errorInfo = createErrorInfo(errorMessage, errorCode, statusCode) - return createAblyException(errorInfo, cause) -} - -internal fun ablyException( - errorInfo: ErrorInfo, - cause: Throwable? = null, -): AblyException = createAblyException(errorInfo, cause) - -private fun createErrorInfo( - errorMessage: String, - errorCode: ErrorCode, - statusCode: HttpStatusCode, -) = ErrorInfo(errorMessage, statusCode.code, errorCode.code) - -private fun createAblyException( - errorInfo: ErrorInfo, - cause: Throwable?, -) = cause?.let { AblyException.fromErrorInfo(it, errorInfo) } - ?: AblyException.fromErrorInfo(errorInfo) - -internal fun clientError(errorMessage: String) = ablyException(errorMessage, ErrorCode.BadRequest, HttpStatusCode.BadRequest) - -internal fun serverError(errorMessage: String) = ablyException(errorMessage, ErrorCode.InternalError, HttpStatusCode.InternalServerError) - -internal fun objectError(errorMessage: String, cause: Throwable? = null): AblyException { - return ablyException(errorMessage, ErrorCode.InvalidObject, HttpStatusCode.InternalServerError, cause) -} - -internal fun invalidInputError(errorMessage: String, cause: Throwable? = null): AblyException { - return ablyException(errorMessage, ErrorCode.InvalidInputParams, HttpStatusCode.InternalServerError, cause) -} - -/** - * Calculates the byte size of a string. - * For non-ASCII, the byte size can be 2–4x the character count. For ASCII, there is no difference. - * e.g. "Hello" has a byte size of 5, while "你" has a byte size of 3 and "😊" has a byte size of 4. - */ -internal val String.byteSize: Int - get() = this.toByteArray(StandardCharsets.UTF_8).size - -/** - * A channel-specific coroutine scope for executing callbacks asynchronously in the RealtimeObjects system. - * Provides safe execution of suspend functions with results delivered via callbacks. - * Supports proper error handling and cancellation during DefaultRealtimeObjects disposal. - */ -internal class ObjectsAsyncScope(channelName: String) { - private val tag = "ObjectsCallbackScope-$channelName" - - private val scope = - CoroutineScope(Dispatchers.Default + CoroutineName(tag) + SupervisorJob()) - - internal fun launchWithCallback(callback: ObjectsCallback, block: suspend () -> T) { - scope.launch { - try { - val result = block() - try { callback.onSuccess(result) } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing callback's onSuccess handler", t) - } // catch and don't rethrow error from callback - } catch (throwable: Throwable) { - when (throwable) { - is AblyException -> { callback.onError(throwable) } - else -> { - val ex = ablyException("Error executing operation", ErrorCode.BadRequest, cause = throwable) - callback.onError(ex) - } - } - } - } - } - - internal fun launchWithVoidCallback(callback: ObjectsCallback, block: suspend () -> Unit) { - scope.launch { - try { - block() - try { callback.onSuccess(null) } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing callback's onSuccess handler", t) - } // catch and don't rethrow error from callback - } catch (throwable: Throwable) { - when (throwable) { - is AblyException -> { callback.onError(throwable) } - else -> { - val ex = ablyException("Error executing operation", ErrorCode.BadRequest, cause = throwable) - callback.onError(ex) - } - } - } - } - } - - internal fun cancel(cause: CancellationException) { - scope.coroutineContext.cancelChildren(cause) - } -} - -/** - * Generates a random nonce string for object creation. - */ -internal fun generateNonce(): String { - val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" // avoid calculation using range - return (1..16).map { chars.random() }.joinToString("") -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt deleted file mode 100644 index 8267a360d..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.ably.lib.objects.serialization - -import com.google.gson.* -import io.ably.lib.objects.* - -import io.ably.lib.objects.ObjectMessage -import org.msgpack.core.MessagePacker -import org.msgpack.core.MessageUnpacker - -/** - * Default implementation of {@link ObjectsSerializer} that handles serialization/deserialization - * of ObjectMessage arrays for both JSON and MessagePack formats using Jackson and Gson. - * Dynamically loaded by ObjectsHelper#getSerializer() to avoid hard dependencies. - */ -@Suppress("unused") // Used via reflection in ObjectsHelper -internal class DefaultObjectsSerializer : ObjectsSerializer { - - override fun readMsgpackArray(unpacker: MessageUnpacker): Array { - val objectMessagesCount = unpacker.unpackArrayHeader() - return Array(objectMessagesCount) { readObjectMessage(unpacker) } - } - - override fun writeMsgpackArray(objects: Array, packer: MessagePacker) { - val objectMessages = objects.map { it as ObjectMessage } - packer.packArrayHeader(objectMessages.size) - objectMessages.forEach { it.writeMsgpack(packer) } - } - - override fun readFromJsonArray(json: JsonArray): Array { - return json.map { element -> - if (element.isJsonObject) element.asJsonObject.toObjectMessage() - else throw JsonParseException("Expected JsonObject, but found: $element") - }.toTypedArray() - } - - override fun asJsonArray(objects: Array): JsonArray { - val objectMessages = objects.map { it as ObjectMessage } - val jsonArray = JsonArray() - for (objectMessage in objectMessages) { - jsonArray.add(objectMessage.toJsonObject()) - } - return jsonArray - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt deleted file mode 100644 index fbf5acb88..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt +++ /dev/null @@ -1,67 +0,0 @@ -package io.ably.lib.objects.serialization - -import com.google.gson.* -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperationAction -import java.lang.reflect.Type -import kotlin.enums.EnumEntries - -// Gson instance for JSON serialization/deserialization -internal val gson = GsonBuilder() - .registerTypeAdapter(ObjectOperationAction::class.java, EnumCodeTypeAdapter({ it.code }, ObjectOperationAction.entries)) - .registerTypeAdapter(ObjectsMapSemantics::class.java, EnumCodeTypeAdapter({ it.code }, ObjectsMapSemantics.entries)) - .create() - -internal fun ObjectMessage.toJsonObject(): JsonObject { - return gson.toJsonTree(this).asJsonObject -} - -internal fun JsonObject.toObjectMessage(): ObjectMessage { - return gson.fromJson(this, ObjectMessage::class.java) -} - -internal class EnumCodeTypeAdapter>( - private val getCode: (T) -> Int, - private val enumValues: EnumEntries -) : JsonSerializer, JsonDeserializer { - - override fun serialize(src: T, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { - return JsonPrimitive(getCode(src)) - } - - override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): T { - val code = json.asInt - return enumValues.firstOrNull { getCode(it) == code } ?: enumValues.firstOrNull { getCode(it) == -1 } - ?: throw JsonParseException("Unknown enum code: $code and no Unknown fallback found") - } -} - -internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeserializer { - override fun serialize(src: ObjectData, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { - val obj = JsonObject() - src.objectId?.let { obj.addProperty("objectId", it) } - src.string?.let { obj.addProperty("string", it) } - src.number?.let { obj.addProperty("number", it) } - src.boolean?.let { obj.addProperty("boolean", it) } - src.bytes?.let { obj.addProperty("bytes", it) } - src.json?.let { obj.addProperty("json", it.toString()) } // Spec: OD4c5 - return obj - } - - override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): ObjectData { - val obj = if (json.isJsonObject) json.asJsonObject else throw JsonParseException("Expected JsonObject") - val objectId = if (obj.has("objectId")) obj.get("objectId").asString else null - val string = if (obj.has("string")) obj.get("string").asString else null - val number = if (obj.has("number")) obj.get("number").asDouble else null - val boolean = if (obj.has("boolean")) obj.get("boolean").asBoolean else null - val bytes = if (obj.has("bytes")) obj.get("bytes").asString else null - val json = if (obj.has("json")) JsonParser.parseString(obj.get("json").asString) else null - - if (objectId == null && string == null && number == null && boolean == null && bytes == null && json == null) { - throw JsonParseException("Since objectId is not present, at least one of the value fields must be present") - } - return ObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json) - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt deleted file mode 100644 index 2eb10d0bd..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt +++ /dev/null @@ -1,909 +0,0 @@ -package io.ably.lib.objects.serialization - -import com.google.gson.JsonElement -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterCreateWithObjectId -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.ErrorCode -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapCreateWithObjectId -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.MapClear -import io.ably.lib.objects.ObjectDelete -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectsCounter -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectsMap -import io.ably.lib.objects.ObjectsMapEntry -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectState -import java.util.Base64 -import io.ably.lib.util.Serialisation -import org.msgpack.core.MessageFormat -import org.msgpack.core.MessagePacker -import org.msgpack.core.MessageUnpacker - -/** - * Write ObjectMessage to MessagePacker - */ -internal fun ObjectMessage.writeMsgpack(packer: MessagePacker) { - var fieldCount = 0 - - if (id != null) fieldCount++ - if (timestamp != null) fieldCount++ - if (clientId != null) fieldCount++ - if (connectionId != null) fieldCount++ - if (extras != null) fieldCount++ - if (operation != null) fieldCount++ - if (objectState != null) fieldCount++ - if (serial != null) fieldCount++ - if (serialTimestamp != null) fieldCount++ - if (siteCode != null) fieldCount++ - - packer.packMapHeader(fieldCount) - - if (id != null) { - packer.packString("id") - packer.packString(id) - } - - if (timestamp != null) { - packer.packString("timestamp") - packer.packLong(timestamp) - } - - if (clientId != null) { - packer.packString("clientId") - packer.packString(clientId) - } - - if (connectionId != null) { - packer.packString("connectionId") - packer.packString(connectionId) - } - - if (extras != null) { - packer.packString("extras") - packer.writePayload(Serialisation.gsonToMsgpack(extras)) - } - - if (operation != null) { - packer.packString("operation") - operation.writeMsgpack(packer) - } - - if (objectState != null) { - packer.packString("object") - objectState.writeMsgpack(packer) - } - - if (serial != null) { - packer.packString("serial") - packer.packString(serial) - } - - if (serialTimestamp != null) { - packer.packString("serialTimestamp") - packer.packLong(serialTimestamp) - } - - if (siteCode != null) { - packer.packString("siteCode") - packer.packString(siteCode) - } -} - -/** - * Read an ObjectMessage from MessageUnpacker - */ -internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage { - if (unpacker.nextFormat == MessageFormat.NIL) { - unpacker.unpackNil() - return ObjectMessage() // default/empty message - } - - val fieldCount = unpacker.unpackMapHeader() - - var id: String? = null - var timestamp: Long? = null - var clientId: String? = null - var connectionId: String? = null - var extras: JsonObject? = null - var operation: ObjectOperation? = null - var objectState: ObjectState? = null - var serial: String? = null - var serialTimestamp: Long? = null - var siteCode: String? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - - if (fieldFormat == MessageFormat.NIL) { - unpacker.unpackNil() - continue - } - - when (fieldName) { - "id" -> id = unpacker.unpackString() - "timestamp" -> timestamp = unpacker.unpackLong() - "clientId" -> clientId = unpacker.unpackString() - "connectionId" -> connectionId = unpacker.unpackString() - "extras" -> extras = Serialisation.msgpackToGson(unpacker.unpackValue()) as? JsonObject - "operation" -> operation = readObjectOperation(unpacker) - "object" -> objectState = readObjectState(unpacker) - "serial" -> serial = unpacker.unpackString() - "serialTimestamp" -> serialTimestamp = unpacker.unpackLong() - "siteCode" -> siteCode = unpacker.unpackString() - else -> unpacker.skipValue() - } - } - - return ObjectMessage( - id = id, - timestamp = timestamp, - clientId = clientId, - connectionId = connectionId, - extras = extras, - operation = operation, - objectState = objectState, - serial = serial, - serialTimestamp = serialTimestamp, - siteCode = siteCode - ) -} - -/** - * Write ObjectOperation to MessagePacker - */ -private fun ObjectOperation.writeMsgpack(packer: MessagePacker) { - var fieldCount = 1 // action is always required - require(objectId.isNotEmpty()) { "objectId must be non-empty per Objects protocol" } - fieldCount++ - - if (mapCreate != null) fieldCount++ - if (mapSet != null) fieldCount++ - if (mapRemove != null) fieldCount++ - if (counterCreate != null) fieldCount++ - if (counterInc != null) fieldCount++ - if (objectDelete != null) fieldCount++ - if (mapCreateWithObjectId != null) fieldCount++ - if (counterCreateWithObjectId != null) fieldCount++ - if (mapClear != null) fieldCount++ - - packer.packMapHeader(fieldCount) - - packer.packString("action") - packer.packInt(action.code) - - // Always include objectId as per Objects protocol - packer.packString("objectId") - packer.packString(objectId) - - if (mapCreate != null) { - packer.packString("mapCreate") - mapCreate.writeMsgpack(packer) - } - - if (mapSet != null) { - packer.packString("mapSet") - mapSet.writeMsgpack(packer) - } - - if (mapRemove != null) { - packer.packString("mapRemove") - mapRemove.writeMsgpack(packer) - } - - if (counterCreate != null) { - packer.packString("counterCreate") - counterCreate.writeMsgpack(packer) - } - - if (counterInc != null) { - packer.packString("counterInc") - counterInc.writeMsgpack(packer) - } - - if (objectDelete != null) { - packer.packString("objectDelete") - packer.packMapHeader(0) // empty map - } - - if (mapCreateWithObjectId != null) { - packer.packString("mapCreateWithObjectId") - mapCreateWithObjectId.writeMsgpack(packer) - } - - if (counterCreateWithObjectId != null) { - packer.packString("counterCreateWithObjectId") - counterCreateWithObjectId.writeMsgpack(packer) - } - - if (mapClear != null) { - packer.packString("mapClear") - packer.packMapHeader(0) // empty map, no fields - } - -} - -/** - * Read ObjectOperation from MessageUnpacker - */ -private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation { - val fieldCount = unpacker.unpackMapHeader() - - var action: ObjectOperationAction? = null - var objectId: String = "" - var mapCreate: MapCreate? = null - var mapSet: MapSet? = null - var mapRemove: MapRemove? = null - var counterCreate: CounterCreate? = null - var counterInc: CounterInc? = null - var objectDelete: ObjectDelete? = null - var mapCreateWithObjectId: MapCreateWithObjectId? = null - var counterCreateWithObjectId: CounterCreateWithObjectId? = null - var mapClear: MapClear? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - - if (fieldFormat == MessageFormat.NIL) { - unpacker.unpackNil() - continue - } - - when (fieldName) { - "action" -> { - val actionCode = unpacker.unpackInt() - action = ObjectOperationAction.entries.firstOrNull { it.code == actionCode } - ?: ObjectOperationAction.entries.firstOrNull { it.code == -1 } - ?: throw objectError("Unknown ObjectOperationAction code: $actionCode and no Unknown fallback found") - } - "objectId" -> objectId = unpacker.unpackString() - "mapCreate" -> mapCreate = readMapCreate(unpacker) - "mapSet" -> mapSet = readMapSet(unpacker) - "mapRemove" -> mapRemove = readMapRemove(unpacker) - "counterCreate" -> counterCreate = readCounterCreate(unpacker) - "counterInc" -> counterInc = readCounterInc(unpacker) - "objectDelete" -> { - unpacker.skipValue() // empty map, just consume it - objectDelete = ObjectDelete - } - "mapCreateWithObjectId" -> mapCreateWithObjectId = readMapCreateWithObjectId(unpacker) - "counterCreateWithObjectId" -> counterCreateWithObjectId = readCounterCreateWithObjectId(unpacker) - "mapClear" -> { - unpacker.skipValue() // empty map, consume it - mapClear = MapClear - } - else -> unpacker.skipValue() - } - } - - if (action == null) { - throw objectError("Missing required 'action' field in ObjectOperation") - } - - return ObjectOperation( - action = action, - objectId = objectId, - mapCreate = mapCreate, - mapSet = mapSet, - mapRemove = mapRemove, - counterCreate = counterCreate, - counterInc = counterInc, - objectDelete = objectDelete, - mapCreateWithObjectId = mapCreateWithObjectId, - counterCreateWithObjectId = counterCreateWithObjectId, - mapClear = mapClear, - ) -} - -/** - * Write ObjectState to MessagePacker - */ -private fun ObjectState.writeMsgpack(packer: MessagePacker) { - var fieldCount = 3 // objectId, siteTimeserials, and tombstone are required - - if (createOp != null) fieldCount++ - if (map != null) fieldCount++ - if (counter != null) fieldCount++ - - packer.packMapHeader(fieldCount) - - packer.packString("objectId") - packer.packString(objectId) - - packer.packString("siteTimeserials") - packer.packMapHeader(siteTimeserials.size) - for ((key, value) in siteTimeserials) { - packer.packString(key) - packer.packString(value) - } - - packer.packString("tombstone") - packer.packBoolean(tombstone) - - if (createOp != null) { - packer.packString("createOp") - createOp.writeMsgpack(packer) - } - - if (map != null) { - packer.packString("map") - map.writeMsgpack(packer) - } - - if (counter != null) { - packer.packString("counter") - counter.writeMsgpack(packer) - } -} - -/** - * Read ObjectState from MessageUnpacker - */ -private fun readObjectState(unpacker: MessageUnpacker): ObjectState { - val fieldCount = unpacker.unpackMapHeader() - - var objectId = "" - var siteTimeserials = mapOf() - var tombstone = false - var createOp: ObjectOperation? = null - var map: ObjectsMap? = null - var counter: ObjectsCounter? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - - if (fieldFormat == MessageFormat.NIL) { - unpacker.unpackNil() - continue - } - - when (fieldName) { - "objectId" -> objectId = unpacker.unpackString() - "siteTimeserials" -> { - val mapSize = unpacker.unpackMapHeader() - val tempMap = mutableMapOf() - for (j in 0 until mapSize) { - val key = unpacker.unpackString() - val value = unpacker.unpackString() - tempMap[key] = value - } - siteTimeserials = tempMap - } - "tombstone" -> tombstone = unpacker.unpackBoolean() - "createOp" -> createOp = readObjectOperation(unpacker) - "map" -> map = readObjectMap(unpacker) - "counter" -> counter = readObjectCounter(unpacker) - else -> unpacker.skipValue() - } - } - - return ObjectState( - objectId = objectId, - siteTimeserials = siteTimeserials, - tombstone = tombstone, - createOp = createOp, - map = map, - counter = counter - ) -} - -/** - * Write MapCreate to MessagePacker - */ -private fun MapCreate.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(2) - packer.packString("semantics") - packer.packInt(semantics.code) - packer.packString("entries") - packer.packMapHeader(entries.size) - for ((key, value) in entries) { - packer.packString(key) - value.writeMsgpack(packer) - } -} - -/** - * Read MapCreate from MessageUnpacker - */ -private fun readMapCreate(unpacker: MessageUnpacker): MapCreate { - val fieldCount = unpacker.unpackMapHeader() - var semantics: ObjectsMapSemantics = ObjectsMapSemantics.LWW - var entries: Map = emptyMap() - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } - when (fieldName) { - "semantics" -> { - val code = unpacker.unpackInt() - semantics = ObjectsMapSemantics.entries.firstOrNull { it.code == code } - ?: ObjectsMapSemantics.entries.firstOrNull { it.code == -1 } - ?: throw objectError("Unknown MapSemantics code: $code and no UNKNOWN fallback found") - } - "entries" -> { - val mapSize = unpacker.unpackMapHeader() - val tempMap = mutableMapOf() - for (j in 0 until mapSize) { - tempMap[unpacker.unpackString()] = readObjectMapEntry(unpacker) - } - entries = tempMap - } - else -> unpacker.skipValue() - } - } - return MapCreate(semantics = semantics, entries = entries) -} - -/** - * Write MapSet to MessagePacker - */ -private fun MapSet.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(2) - packer.packString("key") - packer.packString(key) - packer.packString("value") - value.writeMsgpack(packer) -} - -/** - * Read MapSet from MessageUnpacker - */ -private fun readMapSet(unpacker: MessageUnpacker): MapSet { - val fieldCount = unpacker.unpackMapHeader() - var key: String? = null - var value: ObjectData? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } - when (fieldName) { - "key" -> key = unpacker.unpackString() - "value" -> value = readObjectData(unpacker) - else -> unpacker.skipValue() - } - } - return MapSet( - key = key ?: throw objectError("Missing 'key' in MapSet payload"), - value = value ?: throw objectError("Missing 'value' in MapSet payload") - ) -} - -/** - * Write MapRemove to MessagePacker - */ -private fun MapRemove.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(1) - packer.packString("key") - packer.packString(key) -} - -/** - * Read MapRemove from MessageUnpacker - */ -private fun readMapRemove(unpacker: MessageUnpacker): MapRemove { - val fieldCount = unpacker.unpackMapHeader() - var key: String? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } - when (fieldName) { - "key" -> key = unpacker.unpackString() - else -> unpacker.skipValue() - } - } - return MapRemove(key = key ?: throw objectError("Missing 'key' in MapRemove payload")) -} - -/** - * Write CounterCreate to MessagePacker - */ -private fun CounterCreate.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(1) - packer.packString("count") - packer.packDouble(count) -} - -/** - * Read CounterCreate from MessageUnpacker - */ -private fun readCounterCreate(unpacker: MessageUnpacker): CounterCreate { - val fieldCount = unpacker.unpackMapHeader() - var count: Double? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } - when (fieldName) { - "count" -> count = unpacker.unpackDouble() - else -> unpacker.skipValue() - } - } - return CounterCreate(count = count ?: throw objectError("Missing 'count' in CounterCreate payload")) -} - -/** - * Write CounterInc to MessagePacker - */ -private fun CounterInc.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(1) - packer.packString("number") - packer.packDouble(number) -} - -/** - * Read CounterInc from MessageUnpacker - */ -private fun readCounterInc(unpacker: MessageUnpacker): CounterInc { - val fieldCount = unpacker.unpackMapHeader() - var number: Double? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } - when (fieldName) { - "number" -> number = unpacker.unpackDouble() - else -> unpacker.skipValue() - } - } - return CounterInc(number = number ?: throw objectError("Missing 'number' in CounterInc payload")) -} - -/** - * Write MapCreateWithObjectId to MessagePacker - */ -private fun MapCreateWithObjectId.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(2) - packer.packString("initialValue") - packer.packString(initialValue) - packer.packString("nonce") - packer.packString(nonce) -} - -/** - * Read MapCreateWithObjectId from MessageUnpacker - */ -private fun readMapCreateWithObjectId(unpacker: MessageUnpacker): MapCreateWithObjectId { - val fieldCount = unpacker.unpackMapHeader() - var initialValue: String? = null - var nonce: String? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } - when (fieldName) { - "initialValue" -> initialValue = unpacker.unpackString() - "nonce" -> nonce = unpacker.unpackString() - else -> unpacker.skipValue() - } - } - return MapCreateWithObjectId( - initialValue = initialValue ?: throw objectError("Missing 'initialValue' in MapCreateWithObjectId payload"), - nonce = nonce ?: throw objectError("Missing 'nonce' in MapCreateWithObjectId payload") - ) -} - -/** - * Write CounterCreateWithObjectId to MessagePacker - */ -private fun CounterCreateWithObjectId.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(2) - packer.packString("initialValue") - packer.packString(initialValue) - packer.packString("nonce") - packer.packString(nonce) -} - -/** - * Read CounterCreateWithObjectId from MessageUnpacker - */ -private fun readCounterCreateWithObjectId(unpacker: MessageUnpacker): CounterCreateWithObjectId { - val fieldCount = unpacker.unpackMapHeader() - var initialValue: String? = null - var nonce: String? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } - when (fieldName) { - "initialValue" -> initialValue = unpacker.unpackString() - "nonce" -> nonce = unpacker.unpackString() - else -> unpacker.skipValue() - } - } - return CounterCreateWithObjectId( - initialValue = initialValue ?: throw objectError("Missing 'initialValue' in CounterCreateWithObjectId payload"), - nonce = nonce ?: throw objectError("Missing 'nonce' in CounterCreateWithObjectId payload") - ) -} - -/** - * Write ObjectMap to MessagePacker - */ -private fun ObjectsMap.writeMsgpack(packer: MessagePacker) { - var fieldCount = 0 - - if (semantics != null) fieldCount++ - if (entries != null) fieldCount++ - if (clearTimeserial != null) fieldCount++ - - packer.packMapHeader(fieldCount) - - if (semantics != null) { - packer.packString("semantics") - packer.packInt(semantics.code) - } - - if (entries != null) { - packer.packString("entries") - packer.packMapHeader(entries.size) - for ((key, value) in entries) { - packer.packString(key) - value.writeMsgpack(packer) - } - } - - if (clearTimeserial != null) { - packer.packString("clearTimeserial") - packer.packString(clearTimeserial) - } -} - -/** - * Read ObjectMap from MessageUnpacker - */ -private fun readObjectMap(unpacker: MessageUnpacker): ObjectsMap { - val fieldCount = unpacker.unpackMapHeader() - - var semantics: ObjectsMapSemantics? = null - var entries: Map? = null - var clearTimeserial: String? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - - if (fieldFormat == MessageFormat.NIL) { - unpacker.unpackNil() - continue - } - - when (fieldName) { - "semantics" -> { - val semanticsCode = unpacker.unpackInt() - semantics = ObjectsMapSemantics.entries.firstOrNull { it.code == semanticsCode } - ?: ObjectsMapSemantics.entries.firstOrNull { it.code == -1 } - ?: throw objectError("Unknown MapSemantics code: $semanticsCode and no UNKNOWN fallback found") - } - "entries" -> { - val mapSize = unpacker.unpackMapHeader() - val tempMap = mutableMapOf() - for (j in 0 until mapSize) { - val key = unpacker.unpackString() - val value = readObjectMapEntry(unpacker) - tempMap[key] = value - } - entries = tempMap - } - "clearTimeserial" -> clearTimeserial = unpacker.unpackString() - else -> unpacker.skipValue() - } - } - - return ObjectsMap(semantics = semantics, entries = entries, clearTimeserial = clearTimeserial) -} - -/** - * Write ObjectCounter to MessagePacker - */ -private fun ObjectsCounter.writeMsgpack(packer: MessagePacker) { - var fieldCount = 0 - - if (count != null) fieldCount++ - - packer.packMapHeader(fieldCount) - - if (count != null) { - packer.packString("count") - packer.packDouble(count) - } -} - -/** - * Read ObjectCounter from MessageUnpacker - */ -private fun readObjectCounter(unpacker: MessageUnpacker): ObjectsCounter { - val fieldCount = unpacker.unpackMapHeader() - - var count: Double? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - - if (fieldFormat == MessageFormat.NIL) { - unpacker.unpackNil() - continue - } - - when (fieldName) { - "count" -> count = unpacker.unpackDouble() - else -> unpacker.skipValue() - } - } - - return ObjectsCounter(count = count) -} - -/** - * Write ObjectMapEntry to MessagePacker - */ -private fun ObjectsMapEntry.writeMsgpack(packer: MessagePacker) { - var fieldCount = 0 - - if (tombstone != null) fieldCount++ - if (timeserial != null) fieldCount++ - if (serialTimestamp != null) fieldCount++ - if (data != null) fieldCount++ - - packer.packMapHeader(fieldCount) - - if (tombstone != null) { - packer.packString("tombstone") - packer.packBoolean(tombstone) - } - - if (timeserial != null) { - packer.packString("timeserial") - packer.packString(timeserial) - } - - if (serialTimestamp != null) { - packer.packString("serialTimestamp") - packer.packLong(serialTimestamp) - } - - if (data != null) { - packer.packString("data") - data.writeMsgpack(packer) - } -} - -/** - * Read ObjectMapEntry from MessageUnpacker - */ -private fun readObjectMapEntry(unpacker: MessageUnpacker): ObjectsMapEntry { - val fieldCount = unpacker.unpackMapHeader() - - var tombstone: Boolean? = null - var timeserial: String? = null - var serialTimestamp: Long? = null - var data: ObjectData? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - - if (fieldFormat == MessageFormat.NIL) { - unpacker.unpackNil() - continue - } - - when (fieldName) { - "tombstone" -> tombstone = unpacker.unpackBoolean() - "timeserial" -> timeserial = unpacker.unpackString() - "serialTimestamp" -> serialTimestamp = unpacker.unpackLong() - "data" -> data = readObjectData(unpacker) - else -> unpacker.skipValue() - } - } - - return ObjectsMapEntry(tombstone = tombstone, timeserial = timeserial, serialTimestamp = serialTimestamp, data = data) -} - -/** - * Write ObjectData to MessagePacker - */ -private fun ObjectData.writeMsgpack(packer: MessagePacker) { - var fieldCount = 0 - - if (objectId != null) fieldCount++ - if (string != null) fieldCount++ - if (number != null) fieldCount++ - if (boolean != null) fieldCount++ - if (bytes != null) fieldCount++ - if (json != null) fieldCount++ - - packer.packMapHeader(fieldCount) - - if (objectId != null) { - packer.packString("objectId") - packer.packString(objectId) - } - - if (string != null) { - packer.packString("string") - packer.packString(string) - } - - if (number != null) { - packer.packString("number") - packer.packDouble(number) - } - - if (boolean != null) { - packer.packString("boolean") - packer.packBoolean(boolean) - } - - if (bytes != null) { - val rawBytes = Base64.getDecoder().decode(bytes) - packer.packString("bytes") - packer.packBinaryHeader(rawBytes.size) - packer.writePayload(rawBytes) - } - - if (json != null) { - packer.packString("json") - packer.packString(json.toString()) - } -} - -/** - * Read ObjectData from MessageUnpacker - */ -private fun readObjectData(unpacker: MessageUnpacker): ObjectData { - val fieldCount = unpacker.unpackMapHeader() - var objectId: String? = null - var string: String? = null - var number: Double? = null - var boolean: Boolean? = null - var bytes: String? = null - var json: JsonElement? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - - if (fieldFormat == MessageFormat.NIL) { - unpacker.unpackNil() - continue - } - - when (fieldName) { - "objectId" -> objectId = unpacker.unpackString() - "string" -> string = unpacker.unpackString() - "number" -> number = unpacker.unpackDouble() - "boolean" -> boolean = unpacker.unpackBoolean() - "bytes" -> { - val size = unpacker.unpackBinaryHeader() - val rawBytes = ByteArray(size) - unpacker.readPayload(rawBytes) - bytes = Base64.getEncoder().encodeToString(rawBytes) - } - "json" -> json = JsonParser.parseString(unpacker.unpackString()) - else -> unpacker.skipValue() - } - } - - return ObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json) -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt deleted file mode 100644 index 934789789..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt +++ /dev/null @@ -1,231 +0,0 @@ -package io.ably.lib.objects.type - -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.ObjectsOperationSource -import io.ably.lib.objects.objectError -import io.ably.lib.objects.type.livecounter.noOpCounterUpdate -import io.ably.lib.objects.type.livemap.noOpMapUpdate -import io.ably.lib.util.Clock -import io.ably.lib.util.Log -import io.ably.lib.util.SystemClock - -internal enum class ObjectType(val value: String) { - Map("map"), - Counter("counter") -} - -// Spec: RTLO4b4b -internal val ObjectUpdate.noOp get() = this.update == null - -/** - * Provides common functionality and base implementation for LiveMap and LiveCounter. - * - * @spec RTLO1/RTLO2 - Base class for LiveMap/LiveCounter object - * - * This should also be included in logging - */ -internal abstract class BaseRealtimeObject( - internal val objectId: String, // // RTLO3a - internal val objectType: ObjectType, - internal val clock: Clock = SystemClock.INSTANCE, -) : ObjectLifecycleCoordinator() { - - protected open val tag = "BaseRealtimeObject" - - internal val siteTimeserials = mutableMapOf() // RTLO3b - - internal var createOperationIsMerged = false // RTLO3c - - @Volatile - internal var isTombstoned = false // Accessed from public API for LiveMap/LiveCounter - - private var tombstonedAt: Long? = null - - /** - * This is invoked by ObjectMessage having updated data with parent `ProtocolMessageAction` as `object_sync` - * @return an update describing the changes - * - * @spec RTLM6/RTLC6 - Overrides ObjectMessage with object data state from sync to LiveMap/LiveCounter - */ - internal fun applyObjectSync(objectMessage: ObjectMessage): ObjectUpdate { - val objectState = objectMessage.objectState as ObjectState // we have non-null objectState here due to RTO5f - validate(objectState) - // object's site serials are still updated even if it is tombstoned, so always use the site serials received from the operation. - // should default to empty map if site serials do not exist on the object state, so that any future operation may be applied to this object. - siteTimeserials.clear() - siteTimeserials.putAll(objectState.siteTimeserials) // RTLC6a, RTLM6a - - if (isTombstoned) { - // this object is tombstoned. this is a terminal state which can't be overridden. skip the rest of object state message processing - if (objectType == ObjectType.Map) { - return noOpMapUpdate - } - return noOpCounterUpdate - } - return applyObjectState(objectState, objectMessage) // RTLM6, RTLC6 - } - - /** - * This is invoked by ObjectMessage having updated data with parent `ProtocolMessageAction` as `object` - * @return true if the operation was meaningfully applied, false otherwise - * - * @spec RTLM15/RTLC7 - Applies ObjectMessage with object data operations to LiveMap/LiveCounter - */ - internal fun applyObject(objectMessage: ObjectMessage, source: ObjectsOperationSource): Boolean { - validateObjectId(objectMessage.operation?.objectId) - - val msgTimeSerial = objectMessage.serial - val msgSiteCode = objectMessage.siteCode - val objectOperation = objectMessage.operation as ObjectOperation - - if (!canApplyOperation(msgSiteCode, msgTimeSerial)) { - // RTLC7b, RTLM15b - Log.v( - tag, - "Skipping ${objectOperation.action} op: op serial $msgTimeSerial <= site serial ${siteTimeserials[msgSiteCode]}; " + - "objectId=$objectId" - ) - return false // RTLC7b / RTLM15b - } - // RTLC7c / RTLM15c - only update siteTimeserials for CHANNEL source - if (source == ObjectsOperationSource.CHANNEL) { - siteTimeserials[msgSiteCode!!] = msgTimeSerial!! // RTLC7c, RTLM15c - } - - if (isTombstoned) { - // this object is tombstoned so the operation cannot be applied - return false // RTLC7e / RTLM15e - } - return applyObjectOperation(objectOperation, objectMessage) // RTLC7d - } - - /** - * Checks if an operation can be applied based on serial comparison. - * - * @spec RTLO4a - Serial comparison logic for LiveMap/LiveCounter operations - */ - internal fun canApplyOperation(siteCode: String?, timeSerial: String?): Boolean { - if (timeSerial.isNullOrEmpty()) { - throw objectError("Invalid serial: $timeSerial") // RTLO4a3 - } - if (siteCode.isNullOrEmpty()) { - throw objectError("Invalid site code: $siteCode") // RTLO4a3 - } - val existingSiteSerial = siteTimeserials[siteCode] // RTLO4a4 - return existingSiteSerial == null || timeSerial > existingSiteSerial // RTLO4a5, RTLO4a6 - } - - internal fun validateObjectId(objectId: String?) { - if (this.objectId != objectId) { - throw objectError("Invalid object: incoming objectId=$objectId; $objectType objectId=${this.objectId}") - } - } - - /** - * Marks the object as tombstoned. - */ - internal fun tombstone(serialTimestamp: Long?): ObjectUpdate { - if (serialTimestamp == null) { - Log.w(tag, "Tombstoning object $objectId without serial timestamp, using local timestamp instead") - } - isTombstoned = true - tombstonedAt = serialTimestamp?: clock.currentTimeMillis() - val update = clearData() - // Emit object lifecycle event for deletion - objectLifecycleChanged(ObjectLifecycle.Deleted) - return update - } - - /** - * Checks if the object is eligible for garbage collection. - * - * An object is eligible for garbage collection if it has been tombstoned and - * the time since tombstoning exceeds the specified grace period. - * - * @param gcGracePeriod The grace period in milliseconds that tombstoned objects - * should be kept before being eligible for collection. - * This value is retrieved from the server's connection details - * or defaults to 24 hours if not provided by the server. - * @return true if the object is tombstoned and the grace period has elapsed, - * false otherwise - */ - internal fun isEligibleForGc(gcGracePeriod: Long): Boolean { - val currentTime = clock.currentTimeMillis() - return isTombstoned && tombstonedAt?.let { currentTime - it >= gcGracePeriod } == true - } - - /** - * Validates that the provided object state is compatible with this object. - * Checks object ID, type-specific validations, and any included create operations. - */ - abstract fun validate(state: ObjectState) - - /** - * Applies an object state received during synchronization to this object. - * This method should update the internal data structure with the complete state - * received from the server. - * - * @param objectState The complete state to apply to this object - * @return A map describing the changes made to the object's data - * - */ - abstract fun applyObjectState(objectState: ObjectState, message: ObjectMessage): ObjectUpdate - - /** - * Applies an operation to this object. - * This method handles the specific operation actions (e.g., update, remove) - * by modifying the underlying data structure accordingly. - * - * @param operation The operation containing the action and data to apply - * @param message The complete object message containing the operation - * @return true if the operation was meaningfully applied, false otherwise - * - */ - abstract fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage): Boolean - - /** - * Clears the object's data and returns an update describing the changes. - * This is called during tombstoning and explicit clear operations. - * - * This method: - * 1. Calculates a diff between the current state and an empty state - * 2. Clears all entries from the underlying data structure - * 3. Returns a map containing metadata about what was cleared - * - * The returned map is used to notifying other components about what entries were removed. - * - * @return A map representing the diff of changes made - */ - abstract fun clearData(): ObjectUpdate - - /** - * Notifies subscribers about changes made to this object. Propagates updates through the - * appropriate manager after converting the generic update map to type-specific update objects. - * Spec: RTLO4b4c - */ - abstract fun notifyUpdated(update: ObjectUpdate) - - /** - * Called during garbage collection intervals to clean up expired entries. - * - * This method is invoked periodically (every 5 minutes) by the ObjectsPool - * to perform cleanup of tombstoned data that has exceeded the grace period. - * - * This method should identify and remove entries that: - * - Have been marked as tombstoned - * - Have a tombstone timestamp older than the specified grace period - * - * @param gcGracePeriod The grace period in milliseconds that tombstoned entries - * should be kept before being eligible for removal. - * This value is retrieved from the server's connection details - * or defaults to 24 hours if not provided by the server. - * Must be greater than 2 minutes to ensure proper operation - * ordering and avoid issues with delayed operations. - * - * Implementations typically use single-pass removal techniques to - * efficiently clean up expired data without creating temporary collections. - */ - abstract fun onGCInterval(gcGracePeriod: Long) -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/ObjectLifecycle.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/ObjectLifecycle.kt deleted file mode 100644 index 70abdea85..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/ObjectLifecycle.kt +++ /dev/null @@ -1,84 +0,0 @@ -package io.ably.lib.objects.type - -import io.ably.lib.objects.ObjectsSubscription -import io.ably.lib.util.EventEmitter -import io.ably.lib.util.Log - -/** - * Internal enum representing object lifecycle states - */ -internal enum class ObjectLifecycle { - Created, - Active, - Deleted -} - -/** - * Maps internal ObjectLifecycle values to their corresponding public ObjectLifecycleEvent values. - * Used to determine which events should be emitted when lifecycle changes occur. - * CREATED and ACTIVE map to null (no public event), while DELETED maps to the public DELETED event. - */ -private val objectLifecycleToEventMap = mapOf( - ObjectLifecycle.Created to null, - ObjectLifecycle.Active to null, - ObjectLifecycle.Deleted to ObjectLifecycleEvent.DELETED -) - -/** - * An interface for managing and communicating changes in the lifecycle state of objects. - * - * Implementations should ensure thread-safe event emission and proper lifecycle - * event notifications. - */ -internal interface HandlesObjectLifecycleChange { - /** - * Handles changes in the lifecycle of objects by notifying all registered listeners. - * Implementations should ensure thread-safe event emission to both internal and public listeners. - * Makes sure every event is processed in the order they were received. - * @param newLifecycle The new lifecycle state of the object. - */ - fun objectLifecycleChanged(newLifecycle: ObjectLifecycle) - - /** - * Disposes all registered lifecycle change listeners and cancels any pending operations. - * Should be called when the associated object is no longer needed. - */ - fun disposeObjectLifecycleListeners() -} - -internal abstract class ObjectLifecycleCoordinator : ObjectLifecycleChange, HandlesObjectLifecycleChange { - private val tag = "ObjectLifecycleCoordinator" - // EventEmitter for users of the library - private val objectLifecycleEmitter = ObjectLifecycleEmitter() - - override fun on(event: ObjectLifecycleEvent, listener: ObjectLifecycleChange.Listener): ObjectsSubscription { - objectLifecycleEmitter.on(event, listener) - return ObjectsSubscription { - objectLifecycleEmitter.off(event, listener) - } - } - - override fun off(listener: ObjectLifecycleChange.Listener) = objectLifecycleEmitter.off(listener) - - override fun offAll() = objectLifecycleEmitter.off() - - override fun objectLifecycleChanged(newLifecycle: ObjectLifecycle) { - objectLifecycleToEventMap[newLifecycle]?.let { objectLifecycleEvent -> - objectLifecycleEmitter.emit(objectLifecycleEvent) - } - } - - override fun disposeObjectLifecycleListeners() = offAll() -} - -private class ObjectLifecycleEmitter : EventEmitter() { - private val tag = "ObjectLifecycleEmitter" - override fun apply(listener: ObjectLifecycleChange.Listener?, event: ObjectLifecycleEvent?, vararg args: Any?) { - try { - event?.let { listener?.onLifecycleEvent(it) } - ?: Log.w(tag, "Null event passed to ObjectLifecycleChange listener callback") - } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing listener callback for event: $event", t) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt deleted file mode 100644 index f6a9ee6c6..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt +++ /dev/null @@ -1,137 +0,0 @@ -package io.ably.lib.objects.type.livecounter - -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.ObjectUpdate -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.objects.type.counter.LiveCounter -import io.ably.lib.objects.type.counter.LiveCounterChange -import io.ably.lib.objects.type.counter.LiveCounterUpdate -import io.ably.lib.objects.type.noOp -import java.util.concurrent.atomic.AtomicReference -import io.ably.lib.util.Log -import io.ably.lib.util.SystemClock -import kotlinx.coroutines.runBlocking - -/** - * @spec RTLC1/RTLC2 - LiveCounter implementation extends BaseRealtimeObject - */ -internal class DefaultLiveCounter private constructor( - objectId: String, - private val realtimeObjects: DefaultRealtimeObjects, -) : LiveCounter, BaseRealtimeObject(objectId, ObjectType.Counter, realtimeObjects.clock) { - - override val tag = "LiveCounter" - - /** - * Thread-safe reference to hold the counter data value. - * Accessed from public API for LiveCounter and updated by LiveCounterManager. - */ - internal val data = AtomicReference(0.0) // RTLC3 - - /** - * liveCounterManager instance for managing LiveCounter operations - */ - private val liveCounterManager = LiveCounterManager(this) - - private val channelName = realtimeObjects.channelName - private val adapter: ObjectsAdapter get() = realtimeObjects.adapter - private val asyncScope get() = realtimeObjects.asyncScope - - override fun increment(amount: Number) = runBlocking { incrementAsync(amount.toDouble()) } - - override fun decrement(amount: Number) = runBlocking { incrementAsync(-amount.toDouble()) } - - override fun incrementAsync(amount: Number, callback: ObjectsCallback) { - asyncScope.launchWithVoidCallback(callback) { incrementAsync(amount.toDouble()) } - } - - override fun decrementAsync(amount: Number, callback: ObjectsCallback) { - asyncScope.launchWithVoidCallback(callback) { incrementAsync(-amount.toDouble()) } - } - - override fun value(): Double { - adapter.throwIfInvalidAccessApiConfiguration(channelName) - return data.get() - } - - override fun subscribe(listener: LiveCounterChange.Listener): ObjectsSubscription { - adapter.throwIfInvalidAccessApiConfiguration(channelName) - return liveCounterManager.subscribe(listener) - } - - override fun unsubscribe(listener: LiveCounterChange.Listener) = liveCounterManager.unsubscribe(listener) - - override fun unsubscribeAll() = liveCounterManager.unsubscribeAll() - - override fun validate(state: ObjectState) = liveCounterManager.validate(state) - - private suspend fun incrementAsync(amount: Double) { - // RTLC12b, RTLC12c, RTLC12d - Validate write API configuration - adapter.throwIfInvalidWriteApiConfiguration(channelName) - - // RTLC12e1 - Validate input parameter - if (amount.isNaN() || amount.isInfinite()) { - throw invalidInputError("Counter value increment should be a valid number") - } - - // RTLC12e2, RTLC12e3, RTLC12e4 - Create ObjectMessage with the COUNTER_INC operation - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = objectId, - counterInc = CounterInc(number = amount) - ) - ) - - // RTLC12g - publish and apply locally on ACK - realtimeObjects.publishAndApply(arrayOf(msg)) - } - - override fun applyObjectState(objectState: ObjectState, message: ObjectMessage): LiveCounterUpdate { - return liveCounterManager.applyState(objectState, message.serialTimestamp) - } - - override fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage): Boolean { - return liveCounterManager.applyOperation(operation, message.serialTimestamp) - } - - override fun clearData(): LiveCounterUpdate { - return liveCounterManager.calculateUpdateFromDataDiff(data.get(), 0.0).apply { data.set(0.0) } - } - - override fun notifyUpdated(update: ObjectUpdate) { - if (update.noOp) { - return - } - Log.v(tag, "Object $objectId updated: $update") - liveCounterManager.notify(update as LiveCounterUpdate) - } - - override fun onGCInterval(gcGracePeriod: Long) { - // Nothing to GC for a counter object - return - } - - companion object { - /** - * Creates a zero-value counter object. - * @spec RTLC4 - Returns LiveCounter with 0 value - */ - internal fun zeroValue(objectId: String, realtimeObjects: DefaultRealtimeObjects): DefaultLiveCounter { - return DefaultLiveCounter(objectId, realtimeObjects) - } - - /** - * Creates initial value payload for counter creation. - * Spec: RTO12f12 - */ - internal fun initialValue(count: Number): CounterCreate { - return CounterCreate(count = count.toDouble()) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt deleted file mode 100644 index a1940dc04..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.ably.lib.objects.type.livecounter - -import io.ably.lib.objects.ObjectsSubscription -import io.ably.lib.objects.type.counter.LiveCounterChange -import io.ably.lib.objects.type.counter.LiveCounterUpdate -import io.ably.lib.util.EventEmitter -import io.ably.lib.util.Log - -internal val noOpCounterUpdate = LiveCounterUpdate() - -/** - * Interface for handling live counter changes by notifying subscribers of updates. - * Implementations typically propagate updates through event emission to registered listeners. - */ -internal interface HandlesLiveCounterChange { - /** - * Notifies all registered listeners about a counter update by propagating the change through the event system. - * This method is called when counter data changes and triggers the emission of update events to subscribers. - */ - fun notify(update: LiveCounterUpdate) -} - -internal abstract class LiveCounterChangeCoordinator: LiveCounterChange, HandlesLiveCounterChange { - private val counterChangeEmitter = LiveCounterChangeEmitter() - - override fun subscribe(listener: LiveCounterChange.Listener): ObjectsSubscription { - counterChangeEmitter.on(listener) - return ObjectsSubscription { - counterChangeEmitter.off(listener) - } - } - - override fun unsubscribe(listener: LiveCounterChange.Listener) = counterChangeEmitter.off(listener) - - override fun unsubscribeAll() = counterChangeEmitter.off() - - override fun notify(update: LiveCounterUpdate) = counterChangeEmitter.emit(update) -} - -private class LiveCounterChangeEmitter : EventEmitter() { - private val tag = "LiveCounterChangeEmitter" - - override fun apply(listener: LiveCounterChange.Listener?, event: LiveCounterUpdate?, vararg args: Any?) { - try { - event?.let { listener?.onUpdated(it) } - ?: Log.w(tag, "Null event passed to LiveCounterChange listener callback") - } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing listener callback for event: $event", t) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt deleted file mode 100644 index b9c35bc37..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt +++ /dev/null @@ -1,134 +0,0 @@ -package io.ably.lib.objects.type.livecounter - -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.objectError -import io.ably.lib.objects.type.counter.LiveCounterUpdate -import io.ably.lib.util.Log - -internal class LiveCounterManager(private val liveCounter: DefaultLiveCounter): LiveCounterChangeCoordinator() { - - private val objectId = liveCounter.objectId - - private val tag = "LiveCounterManager" - - /** - * @spec RTLC6 - Overrides counter data with state from sync - */ - internal fun applyState(objectState: ObjectState, serialTimestamp: Long?): LiveCounterUpdate { - val previousData = liveCounter.data.get() - - if (objectState.tombstone) { - liveCounter.tombstone(serialTimestamp) - } else { - // override data for this object with data from the object state - liveCounter.createOperationIsMerged = false // RTLC6b - liveCounter.data.set(objectState.counter?.count ?: 0.0) // RTLC6c - - // RTLC6d - objectState.createOp?.let { createOp -> - mergeInitialDataFromCreateOperation(createOp) - } - } - - return calculateUpdateFromDataDiff(previousData, liveCounter.data.get()) - } - - /** - * @spec RTLC7 - Applies operations to LiveCounter - */ - internal fun applyOperation(operation: ObjectOperation, serialTimestamp: Long?): Boolean { - return when (operation.action) { - ObjectOperationAction.CounterCreate -> { - val update = applyCounterCreate(operation) // RTLC7d1 - liveCounter.notifyUpdated(update) // RTLC7d1a - true // RTLC7d1b - } - ObjectOperationAction.CounterInc -> { - if (operation.counterInc != null) { - val update = applyCounterInc(operation.counterInc) // RTLC7d2 - liveCounter.notifyUpdated(update) // RTLC7d2a - true // RTLC7d2b - } else { - throw objectError("No payload found for ${operation.action} op for LiveCounter objectId=${objectId}") - } - } - ObjectOperationAction.ObjectDelete -> { - val update = liveCounter.tombstone(serialTimestamp) - liveCounter.notifyUpdated(update) - true // RTLC7d4b - } - else -> { - Log.w(tag, "Invalid ${operation.action} op for LiveCounter objectId=${objectId}") // RTLC7d3 - false - } - } - } - - /** - * @spec RTLC8 - Applies counter create operation - */ - private fun applyCounterCreate(operation: ObjectOperation): LiveCounterUpdate { - if (liveCounter.createOperationIsMerged) { - // RTLC8b - // There can't be two different create operation for the same object id, because the object id - // fully encodes that operation. This means we can safely ignore any new incoming create operations - // if we already merged it once. - Log.v( - tag, - "Skipping applying COUNTER_CREATE op on a counter instance as it was already applied before; objectId=$objectId" - ) - return noOpCounterUpdate // RTLC8c - } - - return mergeInitialDataFromCreateOperation(operation) // RTLC8c - } - - /** - * @spec RTLC9 - Applies counter increment operation - */ - private fun applyCounterInc(counterInc: CounterInc): LiveCounterUpdate { - val amount = counterInc.number - val previousValue = liveCounter.data.get() - liveCounter.data.set(previousValue + amount) // RTLC9f - return LiveCounterUpdate(amount) - } - - internal fun calculateUpdateFromDataDiff(prevData: Double, newData: Double): LiveCounterUpdate { - return LiveCounterUpdate(newData - prevData) - } - - /** - * @spec RTLC16 - Merges initial data from create operation - */ - private fun mergeInitialDataFromCreateOperation(operation: ObjectOperation): LiveCounterUpdate { - // if a counter object is missing for the COUNTER_CREATE op, the initial value is implicitly 0 in this case. - // note that it is intentional to SUM the incoming count from the create op. - // if we got here, it means that current counter instance is missing the initial value in its data reference, - // which we're going to add now. - val count = operation.counterCreateWithObjectId?.derivedFrom?.count - ?: operation.counterCreate?.count - ?: 0.0 - val previousValue = liveCounter.data.get() - liveCounter.data.set(previousValue + count) // RTLC16 - liveCounter.createOperationIsMerged = true // RTLC16 - return LiveCounterUpdate(count) - } - - internal fun validate(state: ObjectState) { - liveCounter.validateObjectId(state.objectId) - state.createOp?.let { createOp -> - liveCounter.validateObjectId(createOp.objectId) - validateCounterCreateAction(createOp.action) - } - } - - private fun validateCounterCreateAction(action: ObjectOperationAction) { - if (action != ObjectOperationAction.CounterCreate) { - throw objectError("Invalid create operation action $action for LiveCounter objectId=${objectId}") - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt deleted file mode 100644 index da5cee9b4..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt +++ /dev/null @@ -1,247 +0,0 @@ -package io.ably.lib.objects.type.livemap - -import io.ably.lib.objects.* -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.ObjectUpdate -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.objects.type.map.LiveMap -import io.ably.lib.objects.type.map.LiveMapChange -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.objects.type.map.LiveMapValue -import io.ably.lib.objects.type.noOp -import io.ably.lib.util.Log -import io.ably.lib.util.SystemClock -import kotlinx.coroutines.runBlocking -import java.util.Base64 -import java.util.concurrent.ConcurrentHashMap -import java.util.AbstractMap - -/** - * @spec RTLM1/RTLM2 - LiveMap implementation extends BaseRealtimeObject - */ -internal class DefaultLiveMap private constructor( - objectId: String, - private val realtimeObjects: DefaultRealtimeObjects, - internal val semantics: ObjectsMapSemantics = ObjectsMapSemantics.LWW -) : LiveMap, BaseRealtimeObject(objectId, ObjectType.Map, realtimeObjects.clock) { - - override val tag = "LiveMap" - - /** - * ConcurrentHashMap for thread-safe access from public APIs in LiveMap and LiveMapManager. - */ - internal val data = ConcurrentHashMap() - - /** @spec RTLM25 */ - internal var clearTimeserial: String? = null - - /** - * LiveMapManager instance for managing LiveMap operations - */ - private val liveMapManager = LiveMapManager(this) - - private val channelName = realtimeObjects.channelName - private val adapter: ObjectsAdapter get() = realtimeObjects.adapter - internal val objectsPool: ObjectsPool get() = realtimeObjects.objectsPool - private val asyncScope get() = realtimeObjects.asyncScope - - override fun get(keyName: String): LiveMapValue? { - adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM5b, RTLM5c - if (isTombstoned) { - return null - } - data[keyName]?.let { liveMapEntry -> - return liveMapEntry.getResolvedValue(objectsPool) - } - return null // RTLM5d1 - } - - override fun entries(): Iterable> { - adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM11b, RTLM11c - - return sequence> { - for ((key, entry) in data.entries) { - val value = entry.getResolvedValue(objectsPool) // RTLM11d, RTLM11d2 - value?.let { - yield(AbstractMap.SimpleImmutableEntry(key, it)) - } - } - }.asIterable() - } - - override fun keys(): Iterable { - val iterableEntries = entries() - return sequence { - for (entry in iterableEntries) { - yield(entry.key) // RTLM12b - } - }.asIterable() - } - - override fun values(): Iterable { - val iterableEntries = entries() - return sequence { - for (entry in iterableEntries) { - yield(entry.value) // RTLM13b - } - }.asIterable() - } - - override fun size(): Long { - adapter.throwIfInvalidAccessApiConfiguration(channelName) - return data.values.count { !it.isEntryOrRefTombstoned(objectsPool) }.toLong() // RTLM10d - } - - override fun set(keyName: String, value: LiveMapValue) = runBlocking { setAsync(keyName, value) } - - override fun remove(keyName: String) = runBlocking { removeAsync(keyName) } - - override fun setAsync(keyName: String, value: LiveMapValue, callback: ObjectsCallback) { - asyncScope.launchWithVoidCallback(callback) { setAsync(keyName, value) } - } - - override fun removeAsync(keyName: String, callback: ObjectsCallback) { - asyncScope.launchWithVoidCallback(callback) { removeAsync(keyName) } - } - - override fun validate(state: ObjectState) = liveMapManager.validate(state) - - override fun subscribe(listener: LiveMapChange.Listener): ObjectsSubscription { - adapter.throwIfInvalidAccessApiConfiguration(channelName) - return liveMapManager.subscribe(listener) - } - - override fun unsubscribe(listener: LiveMapChange.Listener) = liveMapManager.unsubscribe(listener) - - override fun unsubscribeAll() = liveMapManager.unsubscribeAll() - - private suspend fun setAsync(keyName: String, value: LiveMapValue) { - // RTLM20b, RTLM20c, RTLM20d - Validate write API configuration - adapter.throwIfInvalidWriteApiConfiguration(channelName) - - // Validate input parameters - if (keyName.isEmpty()) { - throw invalidInputError("Map key should not be empty") - } - - // RTLM20e - Create ObjectMessage with the MAP_SET operation - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = objectId, - mapSet = MapSet( - key = keyName, - value = fromLiveMapValue(value) - ) - ) - ) - - // RTLM20g - publish and apply locally on ACK - realtimeObjects.publishAndApply(arrayOf(msg)) - } - - private suspend fun removeAsync(keyName: String) { - // RTLM21b, RTLM21cm RTLM21d - Validate write API configuration - adapter.throwIfInvalidWriteApiConfiguration(channelName) - - // Validate input parameter - if (keyName.isEmpty()) { - throw invalidInputError("Map key should not be empty") - } - - // RTLM21e - Create ObjectMessage with the MAP_REMOVE operation - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = objectId, - mapRemove = MapRemove(key = keyName) - ) - ) - - // RTLM21g - publish and apply locally on ACK - realtimeObjects.publishAndApply(arrayOf(msg)) - } - - override fun applyObjectState(objectState: ObjectState, message: ObjectMessage): LiveMapUpdate { - return liveMapManager.applyState(objectState, message.serialTimestamp) - } - - override fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage): Boolean { - return liveMapManager.applyOperation(operation, message.serial, message.serialTimestamp) - } - - override fun clearData(): LiveMapUpdate { - clearTimeserial = null // RTLM4 - return liveMapManager.calculateUpdateFromDataDiff(data.toMap(), emptyMap()) - .apply { data.clear() } - } - - override fun notifyUpdated(update: ObjectUpdate) { - if (update.noOp) { - return - } - Log.v(tag, "Object $objectId updated: $update") - liveMapManager.notify(update as LiveMapUpdate) - } - - override fun onGCInterval(gcGracePeriod: Long) { - data.entries.removeIf { (_, entry) -> entry.isEligibleForGc(gcGracePeriod, clock) } - } - - companion object { - /** - * Creates a zero-value map object. - * @spec RTLM4 - Returns LiveMap with empty map data - */ - internal fun zeroValue(objectId: String, objects: DefaultRealtimeObjects): DefaultLiveMap { - return DefaultLiveMap(objectId, objects) - } - - /** - * Creates a MapCreate payload from map entries. - * Spec: RTO11f14 - */ - internal fun initialValue(entries: MutableMap): MapCreate { - return MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = entries.mapValues { (_, value) -> - ObjectsMapEntry( - tombstone = false, - data = fromLiveMapValue(value) - ) - } - ) - } - - /** - * Spec: RTLM20e5 - */ - private fun fromLiveMapValue(value: LiveMapValue): ObjectData { - return when { - value.isLiveMap || value.isLiveCounter -> - ObjectData(objectId = (value.value as BaseRealtimeObject).objectId) - value.isBoolean -> - ObjectData(boolean = value.asBoolean) - value.isBinary -> - ObjectData(bytes = Base64.getEncoder().encodeToString(value.asBinary)) - value.isNumber -> - ObjectData(number = value.asNumber.toDouble()) - value.isString -> - ObjectData(string = value.asString) - value.isJsonObject -> - ObjectData(json = value.asJsonObject) - value.isJsonArray -> - ObjectData(json = value.asJsonArray) - else -> - throw IllegalArgumentException("Unsupported value type") - } - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt deleted file mode 100644 index 8bed43497..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.ably.lib.objects.type.livemap - -import io.ably.lib.objects.ObjectsSubscription -import io.ably.lib.objects.type.map.LiveMapChange -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.util.EventEmitter -import io.ably.lib.util.Log - -internal val noOpMapUpdate = LiveMapUpdate() - -/** - * Interface for handling live map changes by notifying subscribers of updates. - * Implementations typically propagate updates through event emission to registered listeners. - */ -internal interface HandlesLiveMapChange { - /** - * Notifies all registered listeners about a map update by propagating the change through the event system. - * This method is called when map data changes and triggers the emission of update events to subscribers. - */ - fun notify(update: LiveMapUpdate) -} - -internal abstract class LiveMapChangeCoordinator: LiveMapChange, HandlesLiveMapChange { - private val mapChangeEmitter = LiveMapChangeEmitter() - - override fun subscribe(listener: LiveMapChange.Listener): ObjectsSubscription { - mapChangeEmitter.on(listener) - return ObjectsSubscription { - mapChangeEmitter.off(listener) - } - } - - override fun unsubscribe(listener: LiveMapChange.Listener) = mapChangeEmitter.off(listener) - - override fun unsubscribeAll() = mapChangeEmitter.off() - - override fun notify(update: LiveMapUpdate) = mapChangeEmitter.emit(update) -} - -private class LiveMapChangeEmitter : EventEmitter() { - private val tag = "LiveMapChangeEmitter" - - override fun apply(listener: LiveMapChange.Listener?, event: LiveMapUpdate?, vararg args: Any?) { - try { - event?.let { listener?.onUpdated(it) } - ?: Log.w(tag, "Null event passed to LiveMapChange listener callback") - } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing listener callback for event: $event", t) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt deleted file mode 100644 index 2b21a7f2f..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt +++ /dev/null @@ -1,86 +0,0 @@ -package io.ably.lib.objects.type.livemap - -import io.ably.lib.objects.* -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectsPool -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.objects.type.counter.LiveCounter -import io.ably.lib.objects.type.map.LiveMap -import io.ably.lib.objects.type.map.LiveMapValue -import io.ably.lib.util.Clock -import java.util.Base64 - -/** - * @spec RTLM3 - Map data structure storing entries - */ -internal data class LiveMapEntry( - val isTombstoned: Boolean = false, - val tombstonedAt: Long? = null, - val timeserial: String? = null, - val data: ObjectData? = null -) - -/** - * Checks if entry is directly tombstoned or references a tombstoned object. Spec: RTLM14 - * @param objectsPool The object pool containing referenced DefaultRealtimeObjects - */ -internal fun LiveMapEntry.isEntryOrRefTombstoned(objectsPool: ObjectsPool): Boolean { - if (isTombstoned) { - return true // RTLM14a - } - data?.objectId?.let { refId -> // RTLM5d2f -has an objectId reference - objectsPool.get(refId)?.let { refObject -> - if (refObject.isTombstoned) { - return true - } - } - } - return false // RTLM14b -} - -/** - * Returns value as is if object data stores a primitive type or - * a reference to another RealtimeObject from the pool if it stores an objectId. - */ -internal fun LiveMapEntry.getResolvedValue(objectsPool: ObjectsPool): LiveMapValue? { - if (isTombstoned) { return null } // RTLM5d2a - - data?.let { d -> // RTLM5d2b, RTLM5d2c, RTLM5d2d, RTLM5d2e - d.string?.let { return LiveMapValue.of(it) } - d.number?.let { return LiveMapValue.of(it) } - d.boolean?.let { return LiveMapValue.of(it) } - d.bytes?.let { return LiveMapValue.of(Base64.getDecoder().decode(it)) } - d.json?.let { parsed -> - return when { - parsed.isJsonObject -> LiveMapValue.of(parsed.asJsonObject) - parsed.isJsonArray -> LiveMapValue.of(parsed.asJsonArray) - else -> null - } - } - d.objectId?.let { refId -> // RTLM5d2f - has an objectId reference - objectsPool.get(refId)?.let { refObject -> - if (refObject.isTombstoned) { - return null // tombstoned objects must not be surfaced to the end users - } - return fromRealtimeObject(refObject) // RTLM5d2f2 - } - } - } - return null // RTLM5d2g, RTLM5d2f1 -} - -/** - * Extension function to check if a LiveMapEntry is expired and ready for garbage collection - */ -internal fun LiveMapEntry.isEligibleForGc(gcGracePeriod: Long, clock: Clock): Boolean { - val currentTime = clock.currentTimeMillis() - return isTombstoned && tombstonedAt?.let { currentTime - it >= gcGracePeriod } == true -} - -private fun fromRealtimeObject(realtimeObject: BaseRealtimeObject): LiveMapValue { - return when (realtimeObject.objectType) { - ObjectType.Map -> LiveMapValue.of(realtimeObject as LiveMap) - ObjectType.Counter -> LiveMapValue.of(realtimeObject as LiveCounter) - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt deleted file mode 100644 index 71cd4e4a2..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt +++ /dev/null @@ -1,410 +0,0 @@ -package io.ably.lib.objects.type.livemap - -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.isInvalid -import io.ably.lib.objects.objectError -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.objects.type.noOp -import io.ably.lib.util.Log - -internal class LiveMapManager(private val liveMap: DefaultLiveMap): LiveMapChangeCoordinator() { - - private val objectId = liveMap.objectId - - private val tag = "LiveMapManager" - - /** - * @spec RTLM6 - Overrides object data with state from sync - */ - internal fun applyState(objectState: ObjectState, serialTimestamp: Long?): LiveMapUpdate { - val previousData = liveMap.data.toMap() - - if (objectState.tombstone) { - liveMap.tombstone(serialTimestamp) - } else { - // override data for this object with data from the object state - liveMap.createOperationIsMerged = false // RTLM6b - liveMap.data.clear() - - liveMap.clearTimeserial = objectState.map?.clearTimeserial // RTLM6i - - objectState.map?.entries?.forEach { (key, entry) -> - liveMap.data[key] = LiveMapEntry( - isTombstoned = entry.tombstone ?: false, - tombstonedAt = if (entry.tombstone == true) entry.serialTimestamp ?: liveMap.clock.currentTimeMillis() else null, - timeserial = entry.timeserial, - data = entry.data - ) - } // RTLM6c - - // RTLM6d - objectState.createOp?.let { createOp -> - mergeInitialDataFromCreateOperation(createOp) - } - } - - return calculateUpdateFromDataDiff(previousData, liveMap.data.toMap()) - } - - /** - * @spec RTLM15 - Applies operations to LiveMap - */ - internal fun applyOperation(operation: ObjectOperation, serial: String?, serialTimestamp: Long?): Boolean { - return when (operation.action) { - ObjectOperationAction.MapCreate -> { - val update = applyMapCreate(operation) // RTLM15d1 - liveMap.notifyUpdated(update) // RTLM15d1a - true // RTLM15d1b - } - ObjectOperationAction.MapSet -> { - if (operation.mapSet != null) { - val update = applyMapSet(operation.mapSet, serial) // RTLM15d2 - liveMap.notifyUpdated(update) // RTLM15d2a - true // RTLM15d2b - } else { - throw objectError("No payload found for ${operation.action} op for LiveMap objectId=${objectId}") - } - } - ObjectOperationAction.MapRemove -> { - if (operation.mapRemove != null) { - val update = applyMapRemove(operation.mapRemove, serial, serialTimestamp) // RTLM15d3 - liveMap.notifyUpdated(update) // RTLM15d3a - true // RTLM15d3b - } else { - throw objectError("No payload found for ${operation.action} op for LiveMap objectId=${objectId}") - } - } - ObjectOperationAction.ObjectDelete -> { - val update = liveMap.tombstone(serialTimestamp) - liveMap.notifyUpdated(update) - true // RTLM15d5b - } - ObjectOperationAction.MapClear -> { - val update = applyMapClear(serial) // RTLM15d8 - liveMap.notifyUpdated(update) // RTLM15d8a - true // RTLM15d8b - } - else -> { - Log.w(tag, "Invalid ${operation.action} op for LiveMap objectId=${objectId}") // RTLM15d4 - false - } - } - } - - /** - * @spec RTLM16 - Applies map create operation - */ - private fun applyMapCreate(operation: ObjectOperation): LiveMapUpdate { - if (liveMap.createOperationIsMerged) { - // RTLM16b - // There can't be two different create operation for the same object id, because the object id - // fully encodes that operation. This means we can safely ignore any new incoming create operations - // if we already merged it once. - Log.v( - tag, - "Skipping applying MAP_CREATE op on a map instance as it was already applied before; objectId=${objectId}" - ) - return noOpMapUpdate - } - - validateMapSemantics(getEffectiveMapCreate(operation)?.semantics) // RTLM16c - - return mergeInitialDataFromCreateOperation(operation) // RTLM16d - } - - /** - * @spec RTLM7 - Applies MAP_SET operation to LiveMap - */ - private fun applyMapSet( - mapSet: MapSet, // RTLM7d1 - timeSerial: String?, // RTLM7d2 - ): LiveMapUpdate { - // RTLM7h - skip if operation is older than the last MAP_CLEAR - val clearSerial = liveMap.clearTimeserial - if (clearSerial != null && (timeSerial == null || clearSerial >= timeSerial)) { - Log.v(tag, - "Skipping MAP_SET for key=\"${mapSet.key}\": op serial $timeSerial <= clear serial $clearSerial; objectId=$objectId") - return noOpMapUpdate - } - - val existingEntry = liveMap.data[mapSet.key] - - // RTLM7a - if (existingEntry != null && !canApplyMapOperation(existingEntry.timeserial, timeSerial)) { - // RTLM7a1 - the operation's serial <= the entry's serial, ignore the operation - Log.v(tag, - "Skipping update for key=\"${mapSet.key}\": op serial $timeSerial <= entry serial ${existingEntry.timeserial};" + - " objectId=${objectId}" - ) - return noOpMapUpdate - } - - if (mapSet.value.isInvalid()) { - throw objectError("Invalid object data for MAP_SET op on objectId=${objectId} on key=${mapSet.key}") - } - - // RTLM7c - mapSet.value.objectId?.let { - // this MAP_SET op is setting a key to point to another object via its object id, - // but it is possible that we don't have the corresponding object in the pool yet (for example, we haven't seen the *_CREATE op for it). - // we don't want to return undefined from this map's .get() method even if we don't have the object, - // so instead we create a zero-value object for that object id if it not exists. - liveMap.objectsPool.createZeroValueObjectIfNotExists(it) // RTLM7c1 - } - - if (existingEntry != null) { - // RTLM7a2 - Replace existing entry with new one instead of mutating - liveMap.data[mapSet.key] = LiveMapEntry( - isTombstoned = false, // RTLM7a2c - timeserial = timeSerial, // RTLM7a2b - data = mapSet.value // RTLM7a2a - ) - } else { - // RTLM7b, RTLM7b1 - liveMap.data[mapSet.key] = LiveMapEntry( - isTombstoned = false, // RTLM7b2 - timeserial = timeSerial, - data = mapSet.value - ) - } - - return LiveMapUpdate(mapOf(mapSet.key to LiveMapUpdate.Change.UPDATED)) - } - - /** - * @spec RTLM8 - Applies MAP_REMOVE operation to LiveMap - */ - private fun applyMapRemove( - mapRemove: MapRemove, // RTLM8c1 - timeSerial: String?, // RTLM8c2 - timeStamp: Long?, // RTLM8c3 - ): LiveMapUpdate { - // RTLM8g - skip if operation is older than the last MAP_CLEAR - val clearSerial = liveMap.clearTimeserial - if (clearSerial != null && (timeSerial == null || clearSerial >= timeSerial)) { - Log.v(tag, - "Skipping MAP_REMOVE for key=\"${mapRemove.key}\": op serial $timeSerial <= clear serial $clearSerial; objectId=$objectId") - return noOpMapUpdate - } - - val existingEntry = liveMap.data[mapRemove.key] - - // RTLM8a - if (existingEntry != null && !canApplyMapOperation(existingEntry.timeserial, timeSerial)) { - // RTLM8a1 - the operation's serial <= the entry's serial, ignore the operation - Log.v( - tag, - "Skipping remove for key=\"${mapRemove.key}\": op serial $timeSerial <= entry serial ${existingEntry.timeserial}; " + - "objectId=${objectId}" - ) - return noOpMapUpdate - } - - val tombstonedAt = if (timeStamp != null) timeStamp else { - Log.w( - tag, - "No timestamp provided for MAP_REMOVE op on key=\"${mapRemove.key}\"; using current time as tombstone time; " + - "objectId=${objectId}" - ) - liveMap.clock.currentTimeMillis() - } - - if (existingEntry != null) { - // RTLM8a2 - Replace existing entry with new one instead of mutating - liveMap.data[mapRemove.key] = LiveMapEntry( - isTombstoned = true, // RTLM8a2c - tombstonedAt = tombstonedAt, - timeserial = timeSerial, // RTLM8a2b - data = null // RTLM8a2a - ) - } else { - // RTLM8b, RTLM8b1 - liveMap.data[mapRemove.key] = LiveMapEntry( - isTombstoned = true, // RTLM8b2 - tombstonedAt = tombstonedAt, - timeserial = timeSerial - ) - } - - return LiveMapUpdate(mapOf(mapRemove.key to LiveMapUpdate.Change.REMOVED)) - } - - /** - * @spec RTLM24 - Applies MAP_CLEAR operation to LiveMap - */ - private fun applyMapClear(timeSerial: String?): LiveMapUpdate { - val clearSerial = liveMap.clearTimeserial - - // RTLM24c - skip if existing clear serial is strictly newer than incoming op serial - if (clearSerial != null && (timeSerial == null || clearSerial > timeSerial)) { - Log.v(tag, - "Skipping MAP_CLEAR: op serial $timeSerial <= current clear serial $clearSerial; objectId=$objectId") - return noOpMapUpdate - } - - Log.v(tag, - "Updating clearTimeserial; previous=$clearSerial, new=$timeSerial; objectId=$objectId") - liveMap.clearTimeserial = timeSerial // RTLM24d - - val update = mutableMapOf() - - // RTLM24e - remove all entries whose serial is older than (or equal to missing) the clear serial - liveMap.data.entries.removeIf { - val (key, entry) = it - val entrySerial = entry.timeserial - if (entrySerial == null || (timeSerial != null && timeSerial > entrySerial)) { - update[key] = LiveMapUpdate.Change.REMOVED - true - } else { - false - } - } - - return LiveMapUpdate(update) - } - - /** - * For Lww CRDT semantics (the only supported LiveMap semantic) an operation - * Should only be applied if incoming serial is strictly greater than existing entry's serial. - * @spec RTLM9 - Serial comparison logic for map operations - */ - private fun canApplyMapOperation(existingMapEntrySerial: String?, timeSerial: String?): Boolean { - if (existingMapEntrySerial.isNullOrEmpty() && timeSerial.isNullOrEmpty()) { // RTLM9b - return false - } - if (existingMapEntrySerial.isNullOrEmpty()) { // RTLM9d - If true, means timeSerial is not empty based on previous checks - return true - } - if (timeSerial.isNullOrEmpty()) { // RTLM9c - Check reached here means existingMapEntrySerial is not empty - return false - } - return timeSerial > existingMapEntrySerial // RTLM9e - both are not empty - } - - /** - * @spec RTLM23 - Merges initial data from create operation - */ - private fun getEffectiveMapCreate(operation: ObjectOperation): MapCreate? = - operation.mapCreateWithObjectId?.derivedFrom ?: operation.mapCreate - - private fun mergeInitialDataFromCreateOperation(operation: ObjectOperation): LiveMapUpdate { - val effectiveMapCreate = getEffectiveMapCreate(operation) - if (effectiveMapCreate?.entries.isNullOrEmpty()) { // no map entries in MAP_CREATE op - return noOpMapUpdate - } - - val aggregatedUpdate = mutableListOf() - - // RTLM23a - // in order to apply MAP_CREATE op for an existing map, we should merge their underlying entries keys. - // we can do this by iterating over entries from MAP_CREATE op and apply changes on per-key basis as if we had MAP_SET, MAP_REMOVE operations. - effectiveMapCreate?.entries?.forEach { (key, entry) -> - // for a MAP_CREATE operation we must use the serial value available on an entry, instead of a serial on a message - val opTimeserial = entry.timeserial - val update = if (entry.tombstone == true) { - // RTLM23a2 - entry in MAP_CREATE op is removed, try to apply MAP_REMOVE op - applyMapRemove(MapRemove(key), opTimeserial, entry.serialTimestamp) - } else { - // RTLM23a1 - entry in MAP_CREATE op is not removed, try to set it via MAP_SET op - applyMapSet(MapSet(key, entry.data ?: throw objectError("MAP_SET operation without data")), opTimeserial) - } - - // skip noop updates - if (update.noOp) { - return@forEach - } - - aggregatedUpdate.add(update) - } - - liveMap.createOperationIsMerged = true // RTLM23b - - return LiveMapUpdate( - aggregatedUpdate.map { it.update }.fold(emptyMap()) { acc, map -> acc + map } - ) - } - - internal fun calculateUpdateFromDataDiff( - prevData: Map, - newData: Map - ): LiveMapUpdate { - val update = mutableMapOf() - - // Check for removed entries - for ((key, prevEntry) in prevData) { - if (!prevEntry.isTombstoned && !newData.containsKey(key)) { - update[key] = LiveMapUpdate.Change.REMOVED - } - } - - // Check for added/updated entries - for ((key, newEntry) in newData) { - if (!prevData.containsKey(key)) { - // if property does not exist in current map, but new data has it as non-tombstoned property - got updated - if (!newEntry.isTombstoned) { - update[key] = LiveMapUpdate.Change.UPDATED - } - // otherwise, if new data has this prop tombstoned - do nothing, as property didn't exist anyway - continue - } - - // properties that exist both in current and new map data need to have their values compared to decide on update type - val prevEntry = prevData[key]!! - - // compare tombstones first - if (prevEntry.isTombstoned && !newEntry.isTombstoned) { - // prev prop is tombstoned, but new is not. it means prop was updated to a meaningful value - update[key] = LiveMapUpdate.Change.UPDATED - continue - } - if (!prevEntry.isTombstoned && newEntry.isTombstoned) { - // prev prop is not tombstoned, but new is. it means prop was removed - update[key] = LiveMapUpdate.Change.REMOVED - continue - } - if (prevEntry.isTombstoned && newEntry.isTombstoned) { - // props are tombstoned - treat as noop, as there is no data to compare - continue - } - - // both props exist and are not tombstoned, need to compare values to see if it was changed - val valueChanged = prevEntry.data != newEntry.data - if (valueChanged) { - update[key] = LiveMapUpdate.Change.UPDATED - continue - } - } - - return LiveMapUpdate(update) - } - - internal fun validate(state: ObjectState) { - liveMap.validateObjectId(state.objectId) - validateMapSemantics(state.map?.semantics) - state.createOp?.let { createOp -> - liveMap.validateObjectId(createOp.objectId) - validateMapCreateAction(createOp.action) - validateMapSemantics(getEffectiveMapCreate(createOp)?.semantics) - } - } - - private fun validateMapCreateAction(action: ObjectOperationAction) { - if (action != ObjectOperationAction.MapCreate) { - throw objectError("Invalid create operation action $action for LiveMap objectId=${objectId}") - } - } - - private fun validateMapSemantics(semantics: ObjectsMapSemantics?) { - if (semantics != liveMap.semantics) { - throw objectError( - "Invalid object: incoming object map semantics=$semantics; current map semantics=${ObjectsMapSemantics.LWW}" - ) - } - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/TestUtils.kt similarity index 98% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt rename to liveobjects/src/test/kotlin/io/ably/lib/object/TestUtils.kt index a91f0e9cf..f71e21727 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/TestUtils.kt @@ -1,4 +1,4 @@ -package io.ably.lib.objects +package io.ably.lib.`object` import java.lang.reflect.Field import kotlinx.coroutines.Dispatchers diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/DefaultRealtimeObjectTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/DefaultRealtimeObjectTest.kt new file mode 100644 index 000000000..1263cb1fb --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/DefaultRealtimeObjectTest.kt @@ -0,0 +1,41 @@ +package io.ably.lib.`object`.integration + +import io.ably.lib.`object`.assertWaiter +import io.ably.lib.`object`.integration.setup.IntegrationTest +import io.ably.lib.realtime.ChannelState +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * Basic integration tests for the path-based LiveObjects implementation. + * + * These exercise the sandbox setup/teardown (see [IntegrationTest]) together with the + * realtime connection and channel lifecycle against a real sandbox app. The path-based + * public Objects API (`channel.object`) is not yet wired to the plugin on this branch - + * it currently resolves to the `RealtimeObject.Unavailable` null-object guard - so these + * tests assert connectivity and the always-present `object` accessor rather than object + * functionality. Functional object tests will be added as the implementation lands. + */ +class DefaultRealtimeObjectTest : IntegrationTest() { + + @Test + fun testRealtimeChannelAttachesOnSandbox() = runTest { + val channelName = generateChannelName() + val channel = getRealtimeChannel(channelName) + assertNotNull(channel) + + channel.attach() + assertWaiter { channel.state == ChannelState.attached } + assertEquals(ChannelState.attached, channel.state) + } + + @Test + fun testRealtimeChannelExposesObjectAccessor() = runTest { + val channelName = generateChannelName() + val channel = getRealtimeChannel(channelName) + // `channel.object` is always non-null (null-object guard) even without the plugin installed. + assertNotNull(channel.`object`) + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/PayloadBuilder.kt similarity index 72% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt rename to liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/PayloadBuilder.kt index 283d11a4f..6c6d30e48 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/PayloadBuilder.kt @@ -1,10 +1,10 @@ -package io.ably.lib.objects.integration.helpers +package io.ably.lib.`object`.integration.helpers import com.google.gson.JsonObject -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.generateNonce -import io.ably.lib.objects.serialization.gson +import io.ably.lib.`object`.generateNonce +import io.ably.lib.`object`.message.WireObjectData +import io.ably.lib.`object`.message.WireObjectOperationAction +import io.ably.lib.`object`.serialization.gson internal object PayloadBuilder { /** @@ -12,11 +12,11 @@ internal object PayloadBuilder { * Maps ObjectOperationAction enum values to their string representations. */ private val ACTION_STRINGS = mapOf( - ObjectOperationAction.MapCreate to "MAP_CREATE", - ObjectOperationAction.MapSet to "MAP_SET", - ObjectOperationAction.MapRemove to "MAP_REMOVE", - ObjectOperationAction.CounterCreate to "COUNTER_CREATE", - ObjectOperationAction.CounterInc to "COUNTER_INC", + WireObjectOperationAction.MapCreate to "MAP_CREATE", + WireObjectOperationAction.MapSet to "MAP_SET", + WireObjectOperationAction.MapRemove to "MAP_REMOVE", + WireObjectOperationAction.CounterCreate to "COUNTER_CREATE", + WireObjectOperationAction.CounterInc to "COUNTER_INC", ) /** @@ -28,11 +28,11 @@ internal object PayloadBuilder { */ internal fun mapCreateRestOp( objectId: String? = null, - data: Map? = null, + data: Map? = null, nonce: String? = null, ): JsonObject { val opBody = JsonObject().apply { - addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapCreate]) + addProperty("operation", ACTION_STRINGS[WireObjectOperationAction.MapCreate]) } if (data != null) { @@ -51,9 +51,9 @@ internal object PayloadBuilder { /** * Creates a MAP_SET operation payload for REST API. */ - internal fun mapSetRestOp(objectId: String, key: String, value: ObjectData): JsonObject { + internal fun mapSetRestOp(objectId: String, key: String, value: WireObjectData): JsonObject { val opBody = JsonObject().apply { - addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapSet]) + addProperty("operation", ACTION_STRINGS[WireObjectOperationAction.MapSet]) addProperty("objectId", objectId) } @@ -71,7 +71,7 @@ internal object PayloadBuilder { */ internal fun mapRemoveRestOp(objectId: String, key: String): JsonObject { val opBody = JsonObject().apply { - addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapRemove]) + addProperty("operation", ACTION_STRINGS[WireObjectOperationAction.MapRemove]) addProperty("objectId", objectId) } @@ -96,7 +96,7 @@ internal object PayloadBuilder { nonce: String? = null, ): JsonObject { val opBody = JsonObject().apply { - addProperty("operation", ACTION_STRINGS[ObjectOperationAction.CounterCreate]) + addProperty("operation", ACTION_STRINGS[WireObjectOperationAction.CounterCreate]) } if (number != null) { @@ -119,7 +119,7 @@ internal object PayloadBuilder { */ internal fun counterIncRestOp(objectId: String, number: Double): JsonObject { val opBody = JsonObject().apply { - addProperty("operation", ACTION_STRINGS[ObjectOperationAction.CounterInc]) + addProperty("operation", ACTION_STRINGS[WireObjectOperationAction.CounterInc]) addProperty("objectId", objectId) add("data", JsonObject().apply { addProperty("number", number) diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/RestObjects.kt similarity index 94% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt rename to liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/RestObjects.kt index d06559377..32d76dac0 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/RestObjects.kt @@ -1,10 +1,10 @@ -package io.ably.lib.objects.integration.helpers +package io.ably.lib.`object`.integration.helpers import com.google.gson.JsonObject -import io.ably.lib.objects.ObjectData +import io.ably.lib.`object`.message.WireObjectData import io.ably.lib.rest.AblyRest import io.ably.lib.http.HttpUtils -import io.ably.lib.objects.integration.helpers.fixtures.DataFixtures +import io.ably.lib.`object`.integration.helpers.fixtures.DataFixtures import io.ably.lib.types.ClientOptions /** @@ -18,7 +18,7 @@ internal class RestObjects(options: ClientOptions) { * Creates a new map object on the channel with optional initial data. * @return The object ID of the created map */ - internal fun createMap(channelName: String, data: Map? = null): String { + internal fun createMap(channelName: String, data: Map? = null): String { val mapCreateOp = PayloadBuilder.mapCreateRestOp(data = data) return operationRequest(channelName, mapCreateOp).objectId ?: throw Exception("Failed to create map: no objectId returned") @@ -27,7 +27,7 @@ internal class RestObjects(options: ClientOptions) { /** * Sets a value (primitives, JsonObject, JsonArray, etc.) at the specified key in an existing map. */ - internal fun setMapValue(channelName: String, mapObjectId: String, key: String, data: ObjectData) { + internal fun setMapValue(channelName: String, mapObjectId: String, key: String, data: WireObjectData) { val mapCreateOp = PayloadBuilder.mapSetRestOp(mapObjectId, key, data) operationRequest(channelName, mapCreateOp) } diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/CounterFixtures.kt similarity index 97% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt rename to liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/CounterFixtures.kt index a8135a9e4..316cab07e 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/CounterFixtures.kt @@ -1,6 +1,6 @@ -package io.ably.lib.objects.integration.helpers.fixtures +package io.ably.lib.`object`.integration.helpers.fixtures -import io.ably.lib.objects.integration.helpers.RestObjects +import io.ably.lib.`object`.integration.helpers.RestObjects /** * Creates a comprehensive test fixture object tree focused on user-context counters. diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/DataFixtures.kt similarity index 65% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt rename to liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/DataFixtures.kt index f6f305aba..4b9783635 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/DataFixtures.kt @@ -1,48 +1,48 @@ -package io.ably.lib.objects.integration.helpers.fixtures +package io.ably.lib.`object`.integration.helpers.fixtures import com.google.gson.JsonArray import com.google.gson.JsonObject -import io.ably.lib.objects.ObjectData +import io.ably.lib.`object`.message.WireObjectData import java.util.Base64 internal object DataFixtures { /** Test fixture for string value ("stringValue") data type */ - internal val stringData = ObjectData(string = "stringValue") + internal val stringData = WireObjectData(string = "stringValue") /** Test fixture for empty string data type */ - internal val emptyStringData = ObjectData(string = "") + internal val emptyStringData = WireObjectData(string = "") /** Test fixture for binary data containing encoded JSON */ - internal val bytesData = ObjectData( + internal val bytesData = WireObjectData( bytes = Base64.getEncoder().encodeToString("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9".toByteArray())) /** Test fixture for empty binary data (zero-length byte array) */ - internal val emptyBytesData = ObjectData(bytes = Base64.getEncoder().encodeToString(ByteArray(0))) + internal val emptyBytesData = WireObjectData(bytes = Base64.getEncoder().encodeToString(ByteArray(0))) /** Test fixture for maximum safe number value */ - internal val maxSafeNumberData = ObjectData(number = 99999999.0) + internal val maxSafeNumberData = WireObjectData(number = 99999999.0) /** Test fixture for minimum safe number value */ - internal val negativeMaxSafeNumberData = ObjectData(number = -99999999.0) + internal val negativeMaxSafeNumberData = WireObjectData(number = -99999999.0) /** Test fixture for positive number value (1) */ - internal val numberData = ObjectData(number = 1.0) + internal val numberData = WireObjectData(number = 1.0) /** Test fixture for zero number value */ - internal val zeroData = ObjectData(number = 0.0) + internal val zeroData = WireObjectData(number = 0.0) /** Test fixture for boolean true value */ - internal val trueData = ObjectData(boolean = true) + internal val trueData = WireObjectData(boolean = true) /** Test fixture for boolean false value */ - internal val falseData = ObjectData(boolean = false) + internal val falseData = WireObjectData(boolean = false) /** Test fixture for JSON object value with single property */ - internal val objectData = ObjectData(json = JsonObject().apply { addProperty("foo", "bar") }) + internal val objectData = WireObjectData(json = JsonObject().apply { addProperty("foo", "bar") }) /** Test fixture for JSON array value with three string elements */ - internal val arrayData = ObjectData( + internal val arrayData = WireObjectData( json = JsonArray().apply { add("foo") add("bar") @@ -54,13 +54,13 @@ internal object DataFixtures { * Creates an ObjectData instance that references another map object. * @param referencedMapObjectId The object ID of the referenced map */ - internal fun mapRef(referencedMapObjectId: String) = ObjectData(objectId = referencedMapObjectId) + internal fun mapRef(referencedMapObjectId: String) = WireObjectData(objectId = referencedMapObjectId) /** * Creates a test fixture map containing all supported data types and values. * @param referencedMapObjectId The object ID to be used for the map reference entry */ - internal fun mapWithAllValues(referencedMapObjectId: String? = null): Map { + internal fun mapWithAllValues(referencedMapObjectId: String? = null): Map { val baseMap = mapOf( "string" to stringData, "emptyString" to emptyStringData, diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/MapFixtures.kt similarity index 89% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt rename to liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/MapFixtures.kt index 475bbe86a..79de1e288 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/MapFixtures.kt @@ -1,7 +1,7 @@ -package io.ably.lib.objects.integration.helpers.fixtures +package io.ably.lib.`object`.integration.helpers.fixtures -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.integration.helpers.RestObjects +import io.ably.lib.`object`.message.WireObjectData +import io.ably.lib.`object`.integration.helpers.RestObjects /** * Initializes a comprehensive test fixture object tree on the specified channel. @@ -114,10 +114,10 @@ internal fun RestObjects.createUserMapObject(channelName: String): String { val preferencesMapObjectId = createMap( channelName, data = mapOf( - "theme" to ObjectData(string = "dark"), - "notifications" to ObjectData(boolean = true), - "language" to ObjectData(string = "en"), - "maxRetries" to ObjectData(number = 3.0) + "theme" to WireObjectData(string = "dark"), + "notifications" to WireObjectData(boolean = true), + "language" to WireObjectData(string = "en"), + "maxRetries" to WireObjectData(number = 3.0) ) ) @@ -127,8 +127,8 @@ internal fun RestObjects.createUserMapObject(channelName: String): String { data = mapOf( "totalLogins" to DataFixtures.mapRef(loginCounterObjectId), "activeSessions" to DataFixtures.mapRef(sessionCounterObjectId), - "lastLoginTime" to ObjectData(string = "2024-01-01T08:30:00Z"), - "profileViews" to ObjectData(number = 42.0) + "lastLoginTime" to WireObjectData(string = "2024-01-01T08:30:00Z"), + "profileViews" to WireObjectData(number = 42.0) ) ) @@ -174,10 +174,10 @@ internal fun RestObjects.createUserProfileMapObject(channelName: String): String return createMap( channelName, data = mapOf( - "userId" to ObjectData(string = "user123"), - "name" to ObjectData(string = "John Doe"), - "email" to ObjectData(string = "john@example.com"), - "isActive" to ObjectData(boolean = true), + "userId" to WireObjectData(string = "user123"), + "name" to WireObjectData(string = "John Doe"), + "email" to WireObjectData(string = "john@example.com"), + "isActive" to WireObjectData(boolean = true), ) ) } diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/setup/IntegrationTest.kt similarity index 96% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/object/integration/setup/IntegrationTest.kt index cb46f2f89..80ab9347d 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/setup/IntegrationTest.kt @@ -1,6 +1,6 @@ -package io.ably.lib.objects.integration.setup +package io.ably.lib.`object`.integration.setup -import io.ably.lib.objects.integration.helpers.RestObjects +import io.ably.lib.`object`.integration.helpers.RestObjects import io.ably.lib.realtime.AblyRealtime import io.ably.lib.realtime.Channel import io.ably.lib.types.ChannelMode diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/setup/Sandbox.kt similarity index 95% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt rename to liveobjects/src/test/kotlin/io/ably/lib/object/integration/setup/Sandbox.kt index cfcd4ed2b..05a7e3ce8 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/integration/setup/Sandbox.kt @@ -1,9 +1,9 @@ -package io.ably.lib.objects.integration.setup +package io.ably.lib.`object`.integration.setup import com.google.gson.JsonElement import com.google.gson.JsonParser -import io.ably.lib.objects.ablyException -import io.ably.lib.objects.integration.helpers.RestObjects +import io.ably.lib.`object`.ablyException +import io.ably.lib.`object`.integration.helpers.RestObjects import io.ably.lib.realtime.* import io.ably.lib.types.ClientOptions import io.ktor.client.* diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/HelpersTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/HelpersTest.kt similarity index 84% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/unit/HelpersTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/object/unit/HelpersTest.kt index 21f5c6792..a8360d758 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/HelpersTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/HelpersTest.kt @@ -1,6 +1,10 @@ -package io.ably.lib.objects.unit +package io.ably.lib.`object`.unit -import io.ably.lib.objects.* +import io.ably.lib.`object`.* +import io.ably.lib.`object`.adapter.AblyClientAdapter +import io.ably.lib.`object`.clientError +import io.ably.lib.`object`.connectionManager +import io.ably.lib.`object`.sendAsync import io.ably.lib.realtime.Channel import io.ably.lib.realtime.ChannelState import io.ably.lib.realtime.ChannelStateListener @@ -19,7 +23,7 @@ class HelpersTest { // sendAsync @Test fun testSendAsyncShouldQueueAccordingToClientOptions() = runTest { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connManager = adapter.connectionManager val clientOptions = ClientOptions().apply { queueMessages = false } @@ -41,7 +45,7 @@ class HelpersTest { @Test fun testSendAsyncErrorPropagatesAblyException() = runTest { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connManager = adapter.connectionManager val clientOptions = ClientOptions() @@ -61,7 +65,7 @@ class HelpersTest { @Test fun testOnGCGracePeriodImmediateInvokesBlock() { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connManager = adapter.connectionManager connManager.setPrivateField("objectsGCGracePeriod", 123L) @@ -74,7 +78,7 @@ class HelpersTest { @Test fun testOnGCGracePeriodDeferredInvokesOnConnectedWithValue() { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connManager = adapter.connectionManager val connection = adapter.connection @@ -93,7 +97,7 @@ class HelpersTest { @Test fun testOnGCGracePeriodDeferredInvokesOnConnectedWithNull() { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connection = adapter.connection var value: Long? = null @@ -110,7 +114,7 @@ class HelpersTest { @Test fun testSendAsyncThrowsWhenConnectionManagerThrows() = runTest { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connManager = adapter.connectionManager val clientOptions = ClientOptions() @@ -127,7 +131,7 @@ class HelpersTest { // attachAsync @Test fun testAttachAsyncSuccess() = runTest { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel every { channel.attach(any()) } answers { @@ -141,7 +145,7 @@ class HelpersTest { @Test fun testAttachAsyncError() = runTest { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel every { channel.attach(any()) } answers { @@ -157,7 +161,7 @@ class HelpersTest { // getChannelModes @Test fun testGetChannelModesPrefersChannelModes() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel every { channel.modes } returns arrayOf(ChannelMode.object_publish) @@ -169,7 +173,7 @@ class HelpersTest { @Test fun testGetChannelModesFallsBackToOptions() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel every { channel.modes } returns emptyArray() @@ -181,7 +185,7 @@ class HelpersTest { @Test fun testGetChannelModesReturnsNullWhenNoModes() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel every { channel.modes } returns null @@ -193,7 +197,7 @@ class HelpersTest { @Test fun testGetChannelModesIgnoresEmptyModes() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel every { channel.modes } returns emptyArray() @@ -206,7 +210,7 @@ class HelpersTest { // setChannelSerial @Test fun testSetChannelSerialSetsWhenObjectActionAndNonEmpty() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) val props = ChannelProperties() channel.properties = props @@ -221,7 +225,7 @@ class HelpersTest { @Test fun testSetChannelSerialNoOpForNonObjectActionOrEmpty() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) val props = ChannelProperties() channel.properties = props @@ -243,7 +247,7 @@ class HelpersTest { // ensureAttached @Test fun testEnsureAttachedFromInitializedAttaches() = runTest { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel @@ -260,7 +264,7 @@ class HelpersTest { @Test fun testEnsureAttachedWhenAlreadyAttachedReturns() = runTest { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.attached @@ -272,7 +276,7 @@ class HelpersTest { @Test fun testEnsureAttachedWaitsForAttachingThenAttached() = runTest { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.attaching @@ -291,7 +295,7 @@ class HelpersTest { @Test fun testEnsureAttachedAttachingButReceivesNonAttachedEmitsError() = runTest { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.attaching @@ -304,37 +308,37 @@ class HelpersTest { listener.onChannelStateChanged(stateChange) } val ex = assertFailsWith { adapter.ensureAttached("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex.errorInfo.code) assertTrue(ex.errorInfo.message.contains("Not attached")) verify(exactly = 1) { channel.once(any()) } } @Test fun testEnsureAttachedThrowsForInvalidState() = runTest { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.failed val ex = assertFailsWith { adapter.ensureAttached("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex.errorInfo.code) } // throwIfInvalidAccessApiConfiguration @Test fun testThrowIfInvalidAccessApiConfigurationStateDetached() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.detached val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex.errorInfo.code) } @Test fun testThrowIfInvalidAccessApiConfigurationMissingMode() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.attached @@ -342,37 +346,37 @@ class HelpersTest { every { channel.options } returns ChannelOptions().apply { modes = null } val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelModeRequired.code, ex.errorInfo.code) assertTrue(ex.errorInfo.message.contains("object_subscribe")) } // throwIfInvalidWriteApiConfiguration @Test fun testThrowIfInvalidWriteApiConfigurationEchoDisabled() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val clientOptions = ClientOptions().apply { echoMessages = false } every { adapter.clientOptions } returns clientOptions val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.BadRequest.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.BadRequest.code, ex.errorInfo.code) assertTrue(ex.errorInfo.message.contains("echoMessages")) } @Test fun testThrowIfInvalidWriteApiConfigurationInvalidState() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) every { adapter.clientOptions } returns ClientOptions() val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.suspended val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex.errorInfo.code) } @Test fun testThrowIfInvalidWriteApiConfigurationMissingMode() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) every { adapter.clientOptions } returns ClientOptions() val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel @@ -381,14 +385,14 @@ class HelpersTest { every { channel.options } returns ChannelOptions().apply { modes = null } val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelModeRequired.code, ex.errorInfo.code) assertTrue(ex.errorInfo.message.contains("object_publish")) } // throwIfUnpublishableState @Test fun testThrowIfUnpublishableStateInactiveConnection() { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connManager = adapter.connectionManager every { connManager.isActive } returns false every { connManager.stateErrorInfo } returns serverError("not active").errorInfo @@ -400,7 +404,7 @@ class HelpersTest { @Test fun testThrowIfUnpublishableStateChannelFailed() { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connManager = adapter.connectionManager every { connManager.isActive } returns true val channel = mockk(relaxed = true) @@ -408,12 +412,12 @@ class HelpersTest { channel.state = ChannelState.failed val ex = assertFailsWith { adapter.throwIfUnpublishableState("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex.errorInfo.code) } @Test fun testAccessConfigThrowsWhenRequiredModeMissing() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.attached @@ -422,13 +426,13 @@ class HelpersTest { every { channel.options } returns ChannelOptions().apply { modes = null } val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelModeRequired.code, ex.errorInfo.code) assertTrue(ex.errorInfo.message.contains("object_subscribe")) } @Test fun testWriteConfigThrowsWhenRequiredModeMissing() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) every { adapter.clientOptions } returns ClientOptions() // echo enabled val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel @@ -437,24 +441,24 @@ class HelpersTest { every { channel.options } returns ChannelOptions().apply { modes = null } val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelModeRequired.code, ex.errorInfo.code) assertTrue(ex.errorInfo.message.contains("object_publish")) } @Test fun testAccessConfigThrowsOnInvalidChannelState() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.detached val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex.errorInfo.code) } @Test fun testWriteConfigThrowsOnInvalidChannelStates() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) every { adapter.clientOptions } returns ClientOptions() val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel @@ -462,12 +466,12 @@ class HelpersTest { // Suspended should be rejected for write config channel.state = ChannelState.suspended val ex1 = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex1.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex1.errorInfo.code) // Failed should also be rejected channel.state = ChannelState.failed val ex2 = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex2.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex2.errorInfo.code) } } diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/ObjectMessageSerializationTest.kt similarity index 90% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/object/unit/ObjectMessageSerializationTest.kt index 776006c41..f3e30b520 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/ObjectMessageSerializationTest.kt @@ -1,11 +1,16 @@ -package io.ably.lib.objects.unit +package io.ably.lib.`object`.unit import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.JsonElement import com.google.gson.JsonNull -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.unit.fixtures.* +import io.ably.lib.`object`.unit.fixtures.dummyObjectMessageWithBinaryData +import io.ably.lib.`object`.unit.fixtures.dummyObjectMessageWithBooleanData +import io.ably.lib.`object`.unit.fixtures.dummyObjectMessageWithJsonArrayData +import io.ably.lib.`object`.unit.fixtures.dummyObjectMessageWithJsonObjectData +import io.ably.lib.`object`.unit.fixtures.dummyObjectMessageWithNumberData +import io.ably.lib.`object`.unit.fixtures.dummyObjectMessageWithStringData +import io.ably.lib.`object`.message.WireObjectMessage import io.ably.lib.types.ProtocolMessage import io.ably.lib.types.ProtocolMessage.ActionSerializer import io.ably.lib.types.ProtocolSerializer @@ -43,7 +48,7 @@ class ObjectMessageSerializationTest { assertNotNull(deserializedProtoMsg) deserializedProtoMsg.state.zip(objectMessages).forEach { (actual, expected) -> - assertEquals(expected, actual as? ObjectMessage) + assertEquals(expected, actual as? WireObjectMessage) } } @@ -62,7 +67,7 @@ class ObjectMessageSerializationTest { assertNotNull(deserializedProtoMsg) deserializedProtoMsg.state.zip(objectMessages).forEach { (actual, expected) -> - assertEquals(expected, (actual as? ObjectMessage)) + assertEquals(expected, (actual as? WireObjectMessage)) } } @@ -169,11 +174,11 @@ class ObjectMessageSerializationTest { // Check if gson deserialization works correctly deserializedProtoMsg = ProtocolSerializer.fromJSON(protoMsgJsonObject.toString()) - assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? ObjectMessage) + assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? WireObjectMessage) // Check if msgpack deserialization works correctly serializedMsgpackBytes = Serialisation.gsonToMsgpack(protoMsgJsonObject) deserializedProtoMsg = ProtocolSerializer.readMsgpack(serializedMsgpackBytes) - assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? ObjectMessage) + assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? WireObjectMessage) } } diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/ObjectMessageSizeTest.kt similarity index 70% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/object/unit/ObjectMessageSizeTest.kt index 4c413649e..ba6ca11c9 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/ObjectMessageSizeTest.kt @@ -1,19 +1,24 @@ -package io.ably.lib.objects.unit +package io.ably.lib.`object`.unit import com.google.gson.JsonObject -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterCreateWithObjectId -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapCreateWithObjectId -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ensureMessageSizeWithinLimit -import io.ably.lib.objects.size +import io.ably.lib.`object`.connectionManager +import io.ably.lib.`object`.ensureMessageSizeWithinLimit +import io.ably.lib.`object`.message.WireCounterCreate +import io.ably.lib.`object`.message.WireCounterCreateWithObjectId +import io.ably.lib.`object`.message.WireCounterInc +import io.ably.lib.`object`.message.WireMapCreate +import io.ably.lib.`object`.message.WireMapCreateWithObjectId +import io.ably.lib.`object`.message.WireMapSet +import io.ably.lib.`object`.message.WireObjectData +import io.ably.lib.`object`.message.WireObjectMessage +import io.ably.lib.`object`.message.WireObjectOperation +import io.ably.lib.`object`.message.WireObjectOperationAction +import io.ably.lib.`object`.message.WireObjectState +import io.ably.lib.`object`.message.WireObjectsCounter +import io.ably.lib.`object`.message.WireObjectsMap +import io.ably.lib.`object`.message.WireObjectsMapEntry +import io.ably.lib.`object`.message.WireObjectsMapSemantics +import io.ably.lib.`object`.message.size import io.ably.lib.transport.Defaults import io.ably.lib.types.AblyException import kotlinx.coroutines.test.runTest @@ -24,12 +29,12 @@ import kotlin.test.assertFailsWith class ObjectMessageSizeTest { @Test fun testObjectMessageSizeWithinLimit() = runTest { - val mockAdapter = getMockObjectsAdapter() + val mockAdapter = getMockAblyClientAdapter() mockAdapter.connectionManager.maxMessageSize = Defaults.maxMessageSize // 64 kb assertEquals(65536, mockAdapter.connectionManager.maxMessageSize) // ObjectMessage with all size-contributing fields - val objectMessage = ObjectMessage( + val objectMessage = WireObjectMessage( id = "msg_12345", // Not counted in size calculation timestamp = 1699123456789L, // Not counted in size calculation clientId = "test-client", // Size: 11 bytes (UTF-8 byte length) @@ -38,40 +43,40 @@ class ObjectMessageSizeTest { addProperty("meta", "data") // JSON: {"meta":"data","count":42} addProperty("count", 42) }, // Total extras size: 26 bytes (verified by gson.toJson().length) - operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, + operation = WireObjectOperation( + action = WireObjectOperationAction.MapCreate, objectId = "obj_54321", // Not counted in operation size // MapSet contributes to operation size - mapSet = MapSet( + mapSet = WireMapSet( key = "mapKey", // Size: 6 bytes (UTF-8 byte length) - value = ObjectData( + value = WireObjectData( objectId = "ref_obj", // Not counted in data size string = "sample" // Size: 6 bytes (UTF-8 byte length) ) // Total ObjectData size: 6 bytes ), // Total MapSet size: 6 + 6 = 12 bytes // CounterInc contributes to operation size - counterInc = CounterInc( + counterInc = WireCounterInc( number = 10.0 // Size: 8 bytes (number is always 8 bytes) ), // Total CounterInc size: 8 bytes // mapCreateWithObjectId.derivedFrom contributes to operation size (for client-initiated MAP_CREATE operations) - mapCreateWithObjectId = MapCreateWithObjectId( + mapCreateWithObjectId = WireMapCreateWithObjectId( nonce = "dummy-nonce", // Not counted in derivedFrom size initialValue = "{}", // Not counted in derivedFrom size - derivedFrom = MapCreate( - semantics = ObjectsMapSemantics.LWW, // Not counted in size + derivedFrom = WireMapCreate( + semantics = WireObjectsMapSemantics.LWW, // Not counted in size entries = mapOf( - "entry1" to ObjectsMapEntry( // Key size: 6 bytes + "entry1" to WireObjectsMapEntry( // Key size: 6 bytes tombstone = false, // Not counted in entry size timeserial = "ts_123", // Not counted in entry size - data = ObjectData( + data = WireObjectData( string = "value1" // Size: 6 bytes ) // ObjectMapEntry size: 6 bytes ), // Total for this entry: 6 (key) + 6 (entry) = 12 bytes - "entry2" to ObjectsMapEntry( // Key size: 6 bytes - data = ObjectData( + "entry2" to WireObjectsMapEntry( // Key size: 6 bytes + data = WireObjectData( number = 42.0 // Size: 8 bytes (number) ) // ObjectMapEntry size: 8 bytes ) // Total for this entry: 6 (key) + 8 (entry) = 14 bytes @@ -80,38 +85,38 @@ class ObjectMessageSizeTest { ), // Total mapCreateWithObjectId size (via derivedFrom): 26 bytes // counterCreateWithObjectId.derivedFrom contributes to operation size (for client-initiated COUNTER_CREATE operations) - counterCreateWithObjectId = CounterCreateWithObjectId( + counterCreateWithObjectId = WireCounterCreateWithObjectId( nonce = "dummy-nonce", // Not counted in derivedFrom size initialValue = "{}", // Not counted in derivedFrom size - derivedFrom = CounterCreate( + derivedFrom = WireCounterCreate( count = 100.0 // Size: 8 bytes (number is always 8 bytes) ), // Total derivedFrom (CounterCreate) size: 8 bytes ), // Total counterCreateWithObjectId size (via derivedFrom): 8 bytes ), // Total ObjectOperation size: 12 + 8 + 26 + 8 = 54 bytes - objectState = ObjectState( + objectState = WireObjectState( objectId = "state_obj", // Not counted in state size siteTimeserials = mapOf("site1" to "serial1"), // Not counted in state size tombstone = false, // Not counted in state size // createOp contributes to state size - createOp = ObjectOperation( - action = ObjectOperationAction.MapSet, + createOp = WireObjectOperation( + action = WireObjectOperationAction.MapSet, objectId = "create_obj", - mapSet = MapSet( + mapSet = WireMapSet( key = "createKey", // Size: 9 bytes - value = ObjectData( + value = WireObjectData( string = "createValue" // Size: 11 bytes ) // ObjectData size: 11 bytes ) // MapSet size: 9 + 11 = 20 bytes ), // Total createOp size: 20 bytes // map contributes to state size - map = ObjectsMap( + map = WireObjectsMap( entries = mapOf( - "stateKey" to ObjectsMapEntry( // Key size: 8 bytes - data = ObjectData( + "stateKey" to WireObjectsMapEntry( // Key size: 8 bytes + data = WireObjectData( string = "stateValue" // Size: 10 bytes ) // ObjectMapEntry size: 10 bytes ) // Total: 8 + 10 = 18 bytes @@ -119,7 +124,7 @@ class ObjectMessageSizeTest { ), // Total ObjectMap size: 18 bytes // counter contributes to state size - counter = ObjectsCounter( + counter = WireObjectsCounter( count = 50.0 // Size: 8 bytes ) // Total ObjectCounter size: 8 bytes ), // Total ObjectState size: 20 + 18 + 8 = 46 bytes @@ -138,13 +143,13 @@ class ObjectMessageSizeTest { @Test fun testObjectMessageSizeForUnicodeCharacters() = runTest { - val objectMessage = ObjectMessage( - operation = ObjectOperation( + val objectMessage = WireObjectMessage( + operation = WireObjectOperation( objectId = "", - action = ObjectOperationAction.MapSet, - mapSet = MapSet( + action = WireObjectOperationAction.MapSet, + mapSet = WireMapSet( key = "", - value = ObjectData( + value = WireObjectData( string = "你😊" // 你 -> 3 bytes, 😊 -> 4 bytes ), ), @@ -155,18 +160,18 @@ class ObjectMessageSizeTest { @Test fun testObjectMessageSizeAboveLimit() = runTest { - val mockAdapter = getMockObjectsAdapter() + val mockAdapter = getMockAblyClientAdapter() mockAdapter.connectionManager.maxMessageSize = Defaults.maxMessageSize // 64 kb assertEquals(65536, mockAdapter.connectionManager.maxMessageSize) // Create ObjectMessage with dummy data that results in size 60kb - val objectMessage1 = ObjectMessage( + val objectMessage1 = WireObjectMessage( clientId = CharArray(60 * 1024) { ('a'..'z').random() }.concatToString() ) assertEquals(60 * 1024, objectMessage1.size()) // Create ObjectMessage with dummy data that results in size 5kb - val objectMessage2 = ObjectMessage( + val objectMessage2 = WireObjectMessage( clientId = CharArray(5 * 1024) { ('a'..'z').random() }.concatToString() ) assertEquals(5 * 1024, objectMessage2.size()) diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/TestHelpers.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/TestHelpers.kt new file mode 100644 index 000000000..3a12eef14 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/TestHelpers.kt @@ -0,0 +1,49 @@ +package io.ably.lib.`object`.unit + +import io.ably.lib.`object`.adapter.AblyClientAdapter +import io.ably.lib.`object`.connectionManager +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.ChannelState +import io.ably.lib.transport.ConnectionManager +import io.ably.lib.types.ChannelMode +import io.ably.lib.types.ChannelOptions +import io.ably.lib.types.ClientOptions +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.spyk + +internal fun getMockRealtimeChannel( + channelName: String, + clientId: String = "client1", + channelModes: Array = arrayOf(ChannelMode.object_publish, ChannelMode.object_subscribe)): Channel { + val client = AblyRealtime(ClientOptions().apply { + autoConnect = false + key = "keyName:Value" + this.clientId = clientId + }) + val channelOpts = ChannelOptions().apply { modes = channelModes } + val channel = client.channels.get(channelName, channelOpts) + return spyk(channel) { + every { attach() } answers { + state = ChannelState.attached + } + every { detach() } answers { + state = ChannelState.detached + } + every { subscribe(any(), any()) } returns mockk(relaxUnitFun = true) + every { subscribe(any>(), any()) } returns mockk(relaxUnitFun = true) + every { subscribe(any()) } returns mockk(relaxUnitFun = true) + }.apply { + state = ChannelState.attached + } +} + +internal fun getMockAblyClientAdapter(): AblyClientAdapter { + mockkStatic("io.ably.lib.object.HelpersKt") + return mockk(relaxed = true) { + every { getChannel(any()) } returns getMockRealtimeChannel("testChannelName") + every { connectionManager } returns mockk(relaxed = true) + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/UtilsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/UtilsTest.kt new file mode 100644 index 000000000..8a64d0987 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/UtilsTest.kt @@ -0,0 +1,93 @@ +package io.ably.lib.`object`.unit + +import io.ably.lib.`object`.* +import io.ably.lib.`object`.ObjectErrorCode +import io.ably.lib.`object`.ObjectHttpStatusCode +import io.ably.lib.`object`.byteSize +import io.ably.lib.`object`.clientError +import io.ably.lib.`object`.generateNonce +import io.ably.lib.types.ErrorInfo +import org.junit.Test +import org.junit.Assert.* + +class UtilsTest { + + @Test + fun testGenerateNonce() { + // Test basic functionality + val nonce1 = generateNonce() + val nonce2 = generateNonce() + + assertEquals(16, nonce1.length) + assertEquals(16, nonce2.length) + assertNotEquals(nonce1, nonce2) // Should be random + + // Test character set + val validChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + val nonce = generateNonce() + nonce.forEach { char -> + assertTrue("Nonce should only contain valid characters", validChars.contains(char)) + } + } + + @Test + fun testStringByteSize() { + // Test ASCII strings + assertEquals(5, "Hello".byteSize) + assertEquals(0, "".byteSize) + assertEquals(1, "A".byteSize) + + // Test non-ASCII strings + assertEquals(3, "你".byteSize) // Chinese character + assertEquals(4, "😊".byteSize) // Emoji + assertEquals(6, "你好".byteSize) // Two Chinese characters + } + + @Test + fun testErrorCreationFunctions() { + // Test clientError + val clientEx = clientError("Bad request") + assertEquals("Bad request", clientEx.errorInfo.message) + assertEquals(ObjectErrorCode.BadRequest.code, clientEx.errorInfo.code) + assertEquals(ObjectHttpStatusCode.BadRequest.code, clientEx.errorInfo.statusCode) + + // Test serverError + val serverEx = serverError("Internal error") + assertEquals("Internal error", serverEx.errorInfo.message) + assertEquals(ObjectErrorCode.InternalError.code, serverEx.errorInfo.code) + assertEquals(ObjectHttpStatusCode.InternalServerError.code, serverEx.errorInfo.statusCode) + + // Test objectError + val objectEx = objectError("Invalid object") + assertEquals("Invalid object", objectEx.errorInfo.message) + assertEquals(ObjectErrorCode.InvalidObject.code, objectEx.errorInfo.code) + assertEquals(ObjectHttpStatusCode.InternalServerError.code, objectEx.errorInfo.statusCode) + + // Test objectError with cause + val cause = RuntimeException("Original error") + val objectExWithCause = objectError("Invalid object", cause) + assertEquals("Invalid object", objectExWithCause.errorInfo.message) + assertEquals(cause, objectExWithCause.cause) + } + + @Test + fun testAblyExceptionCreation() { + // Test with error message and codes + val ex = ablyException("Test error", ObjectErrorCode.BadRequest, ObjectHttpStatusCode.BadRequest) + assertEquals("Test error", ex.errorInfo.message) + assertEquals(ObjectErrorCode.BadRequest.code, ex.errorInfo.code) + assertEquals(ObjectHttpStatusCode.BadRequest.code, ex.errorInfo.statusCode) + + // Test with ErrorInfo + val errorInfo = ErrorInfo("Custom error", 400, 40000) + val ex2 = ablyException(errorInfo) + assertEquals("Custom error", ex2.errorInfo.message) + assertEquals(400, ex2.errorInfo.statusCode) + assertEquals(40000, ex2.errorInfo.code) + + // Test with cause + val cause = RuntimeException("Cause") + val ex3 = ablyException(errorInfo, cause) + assertEquals(cause, ex3.cause) + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/fixtures/ObjectMessageFixtures.kt similarity index 60% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt rename to liveobjects/src/test/kotlin/io/ably/lib/object/unit/fixtures/ObjectMessageFixtures.kt index 6c2f60ccf..f31833b2f 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/fixtures/ObjectMessageFixtures.kt @@ -1,58 +1,63 @@ -package io.ably.lib.objects.unit.fixtures +package io.ably.lib.`object`.unit.fixtures import com.google.gson.JsonArray import com.google.gson.JsonObject -import io.ably.lib.objects.* -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapCreateWithObjectId -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectState +import io.ably.lib.`object`.message.WireMapCreate +import io.ably.lib.`object`.message.WireMapCreateWithObjectId +import io.ably.lib.`object`.message.WireMapSet +import io.ably.lib.`object`.message.WireObjectData +import io.ably.lib.`object`.message.WireObjectMessage +import io.ably.lib.`object`.message.WireObjectOperation +import io.ably.lib.`object`.message.WireObjectOperationAction +import io.ably.lib.`object`.message.WireObjectState +import io.ably.lib.`object`.message.WireObjectsCounter +import io.ably.lib.`object`.message.WireObjectsMap +import io.ably.lib.`object`.message.WireObjectsMapEntry +import io.ably.lib.`object`.message.WireObjectsMapSemantics import java.util.Base64 -internal val dummyObjectDataStringValue = ObjectData(objectId = "object-id", string = "dummy string") +internal val dummyObjectDataStringValue = WireObjectData(objectId = "object-id", string = "dummy string") -internal val dummyBinaryObjectValue = ObjectData(objectId = "object-id", bytes = Base64.getEncoder().encodeToString(byteArrayOf(1, 2, 3))) +internal val dummyBinaryObjectValue = WireObjectData(objectId = "object-id", bytes = Base64.getEncoder().encodeToString(byteArrayOf(1, 2, 3))) -internal val dummyNumberObjectValue = ObjectData(objectId = "object-id", number = 42.0) +internal val dummyNumberObjectValue = WireObjectData(objectId = "object-id", number = 42.0) -internal val dummyBooleanObjectValue = ObjectData(objectId = "object-id", boolean = true) +internal val dummyBooleanObjectValue = WireObjectData(objectId = "object-id", boolean = true) val dummyJsonObject = JsonObject().apply { addProperty("foo", "bar") } -internal val dummyJsonObjectValue = ObjectData(objectId = "object-id", json = dummyJsonObject) +internal val dummyJsonObjectValue = WireObjectData(objectId = "object-id", json = dummyJsonObject) val dummyJsonArray = JsonArray().apply { add(1); add(2); add(3) } -internal val dummyJsonArrayValue = ObjectData(objectId = "object-id", json = dummyJsonArray) +internal val dummyJsonArrayValue = WireObjectData(objectId = "object-id", json = dummyJsonArray) -internal val dummyObjectsMapEntry = ObjectsMapEntry( +internal val dummyObjectsMapEntry = WireObjectsMapEntry( tombstone = false, timeserial = "dummy-timeserial", data = dummyObjectDataStringValue ) -internal val dummyObjectsMap = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, +internal val dummyObjectsMap = WireObjectsMap( + semantics = WireObjectsMapSemantics.LWW, entries = mapOf("dummy-key" to dummyObjectsMapEntry) ) -internal val dummyObjectsCounter = ObjectsCounter( +internal val dummyObjectsCounter = WireObjectsCounter( count = 123.0 ) -internal val dummyMapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, +internal val dummyMapCreate = WireMapCreate( + semantics = WireObjectsMapSemantics.LWW, entries = mapOf("dummy-key" to dummyObjectsMapEntry) ) -internal val dummyObjectOperation = ObjectOperation( - action = ObjectOperationAction.MapCreate, +internal val dummyObjectOperation = WireObjectOperation( + action = WireObjectOperationAction.MapCreate, objectId = "dummy-object-id", mapCreate = dummyMapCreate, - mapCreateWithObjectId = MapCreateWithObjectId(nonce = "dummy-nonce", initialValue = "{\"foo\":\"bar\"}"), + mapCreateWithObjectId = WireMapCreateWithObjectId(nonce = "dummy-nonce", initialValue = "{\"foo\":\"bar\"}"), ) -internal val dummyObjectState = ObjectState( +internal val dummyObjectState = WireObjectState( objectId = "dummy-object-id", siteTimeserials = mapOf("site1" to "serial1"), tombstone = false, @@ -61,7 +66,7 @@ internal val dummyObjectState = ObjectState( counter = dummyObjectsCounter ) -internal val dummyObjectMessage = ObjectMessage( +internal val dummyObjectMessage = WireObjectMessage( id = "dummy-id", timestamp = 1234567890L, clientId = "dummy-client-id", @@ -73,11 +78,11 @@ internal val dummyObjectMessage = ObjectMessage( siteCode = "dummy-site-code" ) -internal fun dummyObjectMessageWithStringData(): ObjectMessage { +internal fun dummyObjectMessageWithStringData(): WireObjectMessage { return dummyObjectMessage } -internal fun dummyObjectMessageWithBinaryData(): ObjectMessage { +internal fun dummyObjectMessageWithBinaryData(): WireObjectMessage { val binaryObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyBinaryObjectValue) val binaryObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to binaryObjectMapEntry)) val binaryMapCreate = dummyMapCreate.copy(entries = mapOf("dummy-key" to binaryObjectMapEntry)) @@ -92,7 +97,7 @@ internal fun dummyObjectMessageWithBinaryData(): ObjectMessage { ) } -internal fun dummyObjectMessageWithNumberData(): ObjectMessage { +internal fun dummyObjectMessageWithNumberData(): WireObjectMessage { val numberObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyNumberObjectValue) val numberObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to numberObjectMapEntry)) val numberMapCreate = dummyMapCreate.copy(entries = mapOf("dummy-key" to numberObjectMapEntry)) @@ -107,7 +112,7 @@ internal fun dummyObjectMessageWithNumberData(): ObjectMessage { ) } -internal fun dummyObjectMessageWithBooleanData(): ObjectMessage { +internal fun dummyObjectMessageWithBooleanData(): WireObjectMessage { val booleanObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyBooleanObjectValue) val booleanObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to booleanObjectMapEntry)) val booleanMapCreate = dummyMapCreate.copy(entries = mapOf("dummy-key" to booleanObjectMapEntry)) @@ -122,14 +127,14 @@ internal fun dummyObjectMessageWithBooleanData(): ObjectMessage { ) } -internal fun dummyObjectMessageWithJsonObjectData(): ObjectMessage { +internal fun dummyObjectMessageWithJsonObjectData(): WireObjectMessage { val jsonObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyJsonObjectValue) val jsonObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to jsonObjectMapEntry)) val jsonMapCreate = dummyMapCreate.copy(entries = mapOf("dummy-key" to jsonObjectMapEntry)) val jsonObjectOperation = dummyObjectOperation.copy( - action = ObjectOperationAction.MapSet, + action = WireObjectOperationAction.MapSet, mapCreate = null, - mapSet = MapSet(key = "dummy-key", value = dummyJsonObjectValue) + mapSet = WireMapSet(key = "dummy-key", value = dummyJsonObjectValue) ) val jsonObjectState = dummyObjectState.copy( map = jsonObjectMap, @@ -141,13 +146,13 @@ internal fun dummyObjectMessageWithJsonObjectData(): ObjectMessage { ) } -internal fun dummyObjectMessageWithJsonArrayData(): ObjectMessage { +internal fun dummyObjectMessageWithJsonArrayData(): WireObjectMessage { val jsonArrayMapEntry = dummyObjectsMapEntry.copy(data = dummyJsonArrayValue) val jsonArrayMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to jsonArrayMapEntry)) val jsonArrayOperation = dummyObjectOperation.copy( - action = ObjectOperationAction.MapSet, + action = WireObjectOperationAction.MapSet, mapCreate = null, - mapSet = MapSet(key = "dummy-key", value = dummyJsonArrayValue) + mapSet = WireMapSet(key = "dummy-key", value = dummyJsonArrayValue) ) val jsonArrayState = dummyObjectState.copy( map = jsonArrayMap, diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt deleted file mode 100644 index 79a99de32..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt +++ /dev/null @@ -1,367 +0,0 @@ -package io.ably.lib.objects.integration - -import io.ably.lib.objects.assertWaiter -import io.ably.lib.objects.integration.helpers.ObjectId -import io.ably.lib.objects.integration.helpers.fixtures.createUserEngagementMatrixMap -import io.ably.lib.objects.integration.helpers.fixtures.createUserMapWithCountersObject -import io.ably.lib.objects.integration.setup.IntegrationTest -import io.ably.lib.objects.type.map.LiveMapValue -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class DefaultLiveCounterTest: IntegrationTest() { - /** - * Tests the synchronization process when a user map object with counters is initialized before channel attach. - * This includes checking the initial values of all counter objects and nested maps in the - * comprehensive user engagement counter structure. - */ - @Test - fun testLiveCounterSync() = runTest { - val channelName = generateChannelName() - val userMapObjectId = restObjects.createUserMapWithCountersObject(channelName) - restObjects.setMapRef(channelName, "root", "user", userMapObjectId) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - // Get the user map object from the root map - val userMap = rootMap.get("user")?.asLiveMap - assertNotNull(userMap, "User map should be synchronized") - assertEquals(7L, userMap.size(), "User map should contain 7 top-level entries") - - // Assert direct counter objects at the top level of the user map - // Test profileViews counter - should have initial value of 127 - val profileViewsCounter = userMap.get("profileViews")?.asLiveCounter - assertNotNull(profileViewsCounter, "Profile views counter should exist") - assertEquals(127.0, profileViewsCounter.value(), "Profile views counter should have initial value of 127") - - // Test postLikes counter - should have initial value of 45 - val postLikesCounter = userMap.get("postLikes")?.asLiveCounter - assertNotNull(postLikesCounter, "Post likes counter should exist") - assertEquals(45.0, postLikesCounter.value(), "Post likes counter should have initial value of 45") - - // Test commentCount counter - should have initial value of 23 - val commentCountCounter = userMap.get("commentCount")?.asLiveCounter - assertNotNull(commentCountCounter, "Comment count counter should exist") - assertEquals(23.0, commentCountCounter.value(), "Comment count counter should have initial value of 23") - - // Test followingCount counter - should have initial value of 89 - val followingCountCounter = userMap.get("followingCount")?.asLiveCounter - assertNotNull(followingCountCounter, "Following count counter should exist") - assertEquals(89.0, followingCountCounter.value(), "Following count counter should have initial value of 89") - - // Test followersCount counter - should have initial value of 156 - val followersCountCounter = userMap.get("followersCount")?.asLiveCounter - assertNotNull(followersCountCounter, "Followers count counter should exist") - assertEquals(156.0, followersCountCounter.value(), "Followers count counter should have initial value of 156") - - // Test loginStreak counter - should have initial value of 7 - val loginStreakCounter = userMap.get("loginStreak")?.asLiveCounter - assertNotNull(loginStreakCounter, "Login streak counter should exist") - assertEquals(7.0, loginStreakCounter.value(), "Login streak counter should have initial value of 7") - - // Assert the nested engagement metrics map - val engagementMetrics = userMap.get("engagementMetrics")?.asLiveMap - assertNotNull(engagementMetrics, "Engagement metrics map should exist") - assertEquals(4L, engagementMetrics.size(), "Engagement metrics map should contain 4 counter entries") - - // Assert counter objects within the engagement metrics map - // Test totalShares counter - should have initial value of 34 - val totalSharesCounter = engagementMetrics.get("totalShares")?.asLiveCounter - assertNotNull(totalSharesCounter, "Total shares counter should exist") - assertEquals(34.0, totalSharesCounter.value(), "Total shares counter should have initial value of 34") - - // Test totalBookmarks counter - should have initial value of 67 - val totalBookmarksCounter = engagementMetrics.get("totalBookmarks")?.asLiveCounter - assertNotNull(totalBookmarksCounter, "Total bookmarks counter should exist") - assertEquals(67.0, totalBookmarksCounter.value(), "Total bookmarks counter should have initial value of 67") - - // Test totalReactions counter - should have initial value of 189 - val totalReactionsCounter = engagementMetrics.get("totalReactions")?.asLiveCounter - assertNotNull(totalReactionsCounter, "Total reactions counter should exist") - assertEquals(189.0, totalReactionsCounter.value(), "Total reactions counter should have initial value of 189") - - // Test dailyActiveStreak counter - should have initial value of 12 - val dailyActiveStreakCounter = engagementMetrics.get("dailyActiveStreak")?.asLiveCounter - assertNotNull(dailyActiveStreakCounter, "Daily active streak counter should exist") - assertEquals(12.0, dailyActiveStreakCounter.value(), "Daily active streak counter should have initial value of 12") - - // Verify that all expected counter keys exist at the top level - val topLevelKeys = userMap.keys().toSet() - val expectedTopLevelKeys = setOf( - "profileViews", "postLikes", "commentCount", "followingCount", - "followersCount", "loginStreak", "engagementMetrics" - ) - assertEquals(expectedTopLevelKeys, topLevelKeys, "Top-level keys should match expected counter keys") - - // Verify that all expected counter keys exist in the engagement metrics map - val engagementKeys = engagementMetrics.keys().toSet() - val expectedEngagementKeys = setOf( - "totalShares", "totalBookmarks", "totalReactions", "dailyActiveStreak" - ) - assertEquals(expectedEngagementKeys, engagementKeys, "Engagement metrics keys should match expected counter keys") - - // Verify total counter values match expectations (useful for integration testing) - val totalUserCounterValues = listOf(127.0, 45.0, 23.0, 89.0, 156.0, 7.0).sum() - val totalEngagementCounterValues = listOf(34.0, 67.0, 189.0, 12.0).sum() - assertEquals(447.0, totalUserCounterValues, "Sum of user counter values should be 447") - assertEquals(302.0, totalEngagementCounterValues, "Sum of engagement counter values should be 302") - } - - /** - * Tests sequential counter operations including creation with initial value, incrementing by various amounts, - * decrementing by various amounts, and validates the resulting counter value after each operation. - */ - @Test - fun testLiveCounterOperations() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - // Step 1: Create a new counter with initial value of 10 - val testCounterObjectId = restObjects.createCounter(channelName, initialValue = 10.0) - restObjects.setMapRef(channelName, "root", "testCounter", testCounterObjectId) - - // Wait for updated testCounter to be available in the root map - assertWaiter { rootMap.get("testCounter") != null } - - // Assert initial state after creation - val testCounter = rootMap.get("testCounter")?.asLiveCounter - assertNotNull(testCounter, "Test counter should be created and accessible") - assertEquals(10.0, testCounter.value(), "Counter should have initial value of 10") - - // Step 2: Increment counter by 5 (10 + 5 = 15) - restObjects.incrementCounter(channelName, testCounterObjectId, 5.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 15.0 } - - // Assert after first increment - assertEquals(15.0, testCounter.value(), "Counter should be incremented to 15") - - // Step 3: Increment counter by 3 (15 + 3 = 18) - restObjects.incrementCounter(channelName, testCounterObjectId, 3.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 18.0 } - - // Assert after second increment - assertEquals(18.0, testCounter.value(), "Counter should be incremented to 18") - - // Step 4: Increment counter by a larger amount: 12 (18 + 12 = 30) - restObjects.incrementCounter(channelName, testCounterObjectId, 12.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 30.0 } - - // Assert after third increment - assertEquals(30.0, testCounter.value(), "Counter should be incremented to 30") - - // Step 5: Decrement counter by 7 (30 - 7 = 23) - restObjects.decrementCounter(channelName, testCounterObjectId, 7.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 23.0 } - - // Assert after first decrement - assertEquals(23.0, testCounter.value(), "Counter should be decremented to 23") - - // Step 6: Decrement counter by 4 (23 - 4 = 19) - restObjects.decrementCounter(channelName, testCounterObjectId, 4.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 19.0 } - - // Assert after second decrement - assertEquals(19.0, testCounter.value(), "Counter should be decremented to 19") - - // Step 7: Increment counter by 1 (19 + 1 = 20) - restObjects.incrementCounter(channelName, testCounterObjectId, 1.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 20.0 } - - // Assert after final increment - assertEquals(20.0, testCounter.value(), "Counter should be incremented to 20") - - // Step 8: Decrement counter by a larger amount: 15 (20 - 15 = 5) - restObjects.decrementCounter(channelName, testCounterObjectId, 15.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 5.0 } - - // Assert after large decrement - assertEquals(5.0, testCounter.value(), "Counter should be decremented to 5") - - // Final verification - test final increment to ensure counter still works - restObjects.incrementCounter(channelName, testCounterObjectId, 25.0) - assertWaiter { testCounter.value() == 30.0 } - - // Assert final state - assertEquals(30.0, testCounter.value(), "Counter should have final value of 30") - - // Verify the counter object is still accessible and functioning - assertNotNull(testCounter, "Counter should still be accessible at the end") - - // Verify we can still access it from the root map - val finalCounterCheck = rootMap.get("testCounter")?.asLiveCounter - assertNotNull(finalCounterCheck, "Counter should still be accessible from root map") - assertEquals(30.0, finalCounterCheck.value(), "Final counter value should be 30 when accessed from root map") - } - - @Test - fun testLiveCounterOperationsUsingRealtime() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val objects = channel.objects - val rootMap = channel.objects.root - - // Step 1: Create a new counter with initial value of 10 - val testCounterObject = objects.createCounter( 10.0) - rootMap.set("testCounter", LiveMapValue.of(testCounterObject)) - - // Wait for updated testCounter to be available in the root map - assertWaiter { rootMap.get("testCounter") != null } - - // Assert initial state after creation - val testCounter = rootMap.get("testCounter")?.asLiveCounter - assertNotNull(testCounter, "Test counter should be created and accessible") - assertEquals(10.0, testCounter.value(), "Counter should have initial value of 10") - - // Step 2: Increment counter by 5 (10 + 5 = 15) - testCounter.increment(5.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 15.0 } - - // Assert after first increment - assertEquals(15.0, testCounter.value(), "Counter should be incremented to 15") - - // Step 3: Increment counter by 3 (15 + 3 = 18) - testCounter.increment(3.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 18.0 } - - // Assert after second increment - assertEquals(18.0, testCounter.value(), "Counter should be incremented to 18") - - // Step 4: Increment counter by a larger amount: 12 (18 + 12 = 30) - testCounter.increment(12.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 30.0 } - - // Assert after third increment - assertEquals(30.0, testCounter.value(), "Counter should be incremented to 30") - - // Step 5: Decrement counter by 7 (30 - 7 = 23) - testCounter.decrement(7.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 23.0 } - - // Assert after first decrement - assertEquals(23.0, testCounter.value(), "Counter should be decremented to 23") - - // Step 6: Decrement counter by 4 (23 - 4 = 19) - testCounter.decrement(4.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 19.0 } - - // Assert after second decrement - assertEquals(19.0, testCounter.value(), "Counter should be decremented to 19") - - // Step 7: Increment counter by 1 (19 + 1 = 20) - testCounter.increment(1.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 20.0 } - - // Assert after final increment - assertEquals(20.0, testCounter.value(), "Counter should be incremented to 20") - - // Step 8: Decrement counter by a larger amount: 15 (20 - 15 = 5) - testCounter.decrement(15.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 5.0 } - - // Assert after large decrement - assertEquals(5.0, testCounter.value(), "Counter should be decremented to 5") - - // Final verification - test final increment to ensure counter still works - testCounter.increment(25.0) - assertWaiter { testCounter.value() == 30.0 } - - // Assert final state - assertEquals(30.0, testCounter.value(), "Counter should have final value of 30") - - // Verify the counter object is still accessible and functioning - assertNotNull(testCounter, "Counter should still be accessible at the end") - - // Verify we can still access it from the root map - val finalCounterCheck = rootMap.get("testCounter")?.asLiveCounter - assertNotNull(finalCounterCheck, "Counter should still be accessible from root map") - assertEquals(30.0, finalCounterCheck.value(), "Final counter value should be 30 when accessed from root map") - } - - @Test - fun testLiveCounterChangesUsingSubscription() = runTest { - val channelName = generateChannelName() - val userEngagementMapId = restObjects.createUserEngagementMatrixMap(channelName) - restObjects.setMapRef(channelName, "root", "userMatrix", userEngagementMapId) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - val userEngagementMap = rootMap.get("userMatrix")?.asLiveMap - assertEquals(4L, userEngagementMap!!.size(), "User engagement map should contain 4 top-level entries") - - val totalReactions = userEngagementMap.get("totalReactions")?.asLiveCounter - assertEquals(189.0, totalReactions!!.value(), "Total reactions counter should have initial value of 189") - - // Subscribe to changes on the totalReactions counter - val counterUpdates = mutableListOf() - val totalReactionsSubscription = totalReactions.subscribe { update -> - counterUpdates.add(update.update.amount) - } - - // Step 1: Increment the totalReactions counter by 10 (189 + 10 = 199) - restObjects.incrementCounter(channelName, totalReactions.ObjectId, 10.0) - - // Wait for the update to be received - assertWaiter { counterUpdates.isNotEmpty() } - - // Verify the increment update was received - assertEquals(1, counterUpdates.size, "Should receive one update for increment") - assertEquals(10.0, counterUpdates.first(), "Update should contain increment amount of 10") - assertEquals(199.0, totalReactions.value(), "Counter should be incremented to 199") - - // Step 2: Decrement the totalReactions counter by 5 (199 - 5 = 194) - counterUpdates.clear() - restObjects.decrementCounter(channelName, totalReactions.ObjectId, 5.0) - - // Wait for the second update - assertWaiter { counterUpdates.isNotEmpty() } - - // Verify the decrement update was received - assertEquals(1, counterUpdates.size, "Should receive one update for decrement") - assertEquals(-5.0, counterUpdates.first(), "Update should contain decrement amount of -5") - assertEquals(194.0, totalReactions.value(), "Counter should be decremented to 194") - - // Step 3: Increment the totalReactions counter by 15 (194 + 15 = 209) - counterUpdates.clear() - restObjects.incrementCounter(channelName, totalReactions.ObjectId, 15.0) - - // Wait for the third update - assertWaiter { counterUpdates.isNotEmpty() } - - // Verify the third increment update was received - assertEquals(1, counterUpdates.size, "Should receive one update for third increment") - assertEquals(15.0, counterUpdates.first(), "Update should contain increment amount of 15") - assertEquals(209.0, totalReactions.value(), "Counter should be incremented to 209") - - // Clean up subscription - counterUpdates.clear() - totalReactionsSubscription.unsubscribe() - - // No updates should be received after unsubscribing - restObjects.incrementCounter(channelName, totalReactions.ObjectId, 20.0) - - // Wait for a moment to ensure no updates are received - assertWaiter { totalReactions.value() == 229.0 } - - assertTrue(counterUpdates.isEmpty(), "No updates should be received after unsubscribing") - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt deleted file mode 100644 index 0f2abb567..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt +++ /dev/null @@ -1,423 +0,0 @@ -package io.ably.lib.objects.integration - -import io.ably.lib.objects.* -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.integration.helpers.fixtures.createUserMapObject -import io.ably.lib.objects.integration.helpers.fixtures.createUserProfileMapObject -import io.ably.lib.objects.integration.setup.IntegrationTest -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.objects.type.map.LiveMapValue -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class DefaultLiveMapTest: IntegrationTest() { - /** - * Tests the synchronization process when a user map object is initialized before channel attach. - * This includes checking the initial values of all nested maps, counters, and primitive data types - * in the comprehensive user map object structure. - */ - @Test - fun testLiveMapSync() = runTest { - val channelName = generateChannelName() - val userMapObjectId = restObjects.createUserMapObject(channelName) - restObjects.setMapRef(channelName, "root", "user", userMapObjectId) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - // Get the user map object from the root map - val userMap = rootMap.get("user")?.asLiveMap - assertNotNull(userMap, "User map should be synchronized") - assertEquals(5L, userMap.size(), "User map should contain 5 top-level entries") - - // Assert Counter Objects - // Test loginCounter - should have initial value of 5 - val loginCounter = userMap.get("loginCounter")?.asLiveCounter - assertNotNull(loginCounter, "Login counter should exist") - assertEquals(5.0, loginCounter.value(), "Login counter should have initial value of 5") - - // Test sessionCounter - should have initial value of 0 - val sessionCounter = userMap.get("sessionCounter")?.asLiveCounter - assertNotNull(sessionCounter, "Session counter should exist") - assertEquals(0.0, sessionCounter.value(), "Session counter should have initial value of 0") - - // Assert User Profile Map - val userProfile = userMap.get("userProfile")?.asLiveMap - assertNotNull(userProfile, "User profile map should exist") - assertEquals(6L, userProfile.size(), "User profile should contain 6 entries") - - // Assert user profile primitive values - assertEquals("user123", userProfile.get("userId")?.asString, "User ID should match expected value") - assertEquals("John Doe", userProfile.get("name")?.asString, "User name should match expected value") - assertEquals("john@example.com", userProfile.get("email")?.asString, "User email should match expected value") - assertEquals(true, userProfile.get("isActive")?.asBoolean, "User should be active") - - // Assert Preferences Map (nested within user profile) - val preferences = userProfile.get("preferences")?.asLiveMap - assertNotNull(preferences, "Preferences map should exist") - assertEquals(4L, preferences.size(), "Preferences should contain 4 entries") - assertEquals("dark", preferences.get("theme")?.asString, "Theme preference should be dark") - assertEquals(true, preferences.get("notifications")?.asBoolean, "Notifications should be enabled") - assertEquals("en", preferences.get("language")?.asString, "Language should be English") - assertEquals(3.0, preferences.get("maxRetries")?.asNumber, "Max retries should be 3") - - // Assert Metrics Map (nested within user profile) - val metrics = userProfile.get("metrics")?.asLiveMap - assertNotNull(metrics, "Metrics map should exist") - assertEquals(4L, metrics.size(), "Metrics should contain 4 entries") - assertEquals("2024-01-01T08:30:00Z", metrics.get("lastLoginTime")?.asString, "Last login time should match") - assertEquals(42.0, metrics.get("profileViews")?.asNumber, "Profile views should be 42") - - // Test counter references within metrics map - val totalLoginsCounter = metrics.get("totalLogins")?.asLiveCounter - assertNotNull(totalLoginsCounter, "Total logins counter should exist") - assertEquals(5.0, totalLoginsCounter.value(), "Total logins should reference login counter with value 5") - - val activeSessionsCounter = metrics.get("activeSessions")?.asLiveCounter - assertNotNull(activeSessionsCounter, "Active sessions counter should exist") - assertEquals(0.0, activeSessionsCounter.value(), "Active sessions should reference session counter with value 0") - - // Assert direct references to maps from top-level user map - val preferencesMapRef = userMap.get("preferencesMap")?.asLiveMap - assertNotNull(preferencesMapRef, "Preferences map reference should exist") - assertEquals(4L, preferencesMapRef.size(), "Referenced preferences map should have 4 entries") - assertEquals("dark", preferencesMapRef.get("theme")?.asString, "Referenced preferences should match nested preferences") - - val metricsMapRef = userMap.get("metricsMap")?.asLiveMap - assertNotNull(metricsMapRef, "Metrics map reference should exist") - assertEquals(4L, metricsMapRef.size(), "Referenced metrics map should have 4 entries") - assertEquals("2024-01-01T08:30:00Z", metricsMapRef.get("lastLoginTime")?.asString, "Referenced metrics should match nested metrics") - - // Verify that references point to the same objects - assertEquals(preferences.get("theme")?.asString, preferencesMapRef.get("theme")?.asString, "Preference references should point to same data") - assertEquals(metrics.get("profileViews")?.asNumber, metricsMapRef.get("profileViews")?.asNumber, "Metrics references should point to same data") - } - - /** - * Tests sequential map operations including creation with initial data, updating existing fields, - * adding new fields, and removing fields. Validates the resulting data after each operation. - */ - @Test - fun testLiveMapOperations() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - // Step 1: Create a new map with initial data - val testMapObjectId = restObjects.createMap( - channelName, - data = mapOf( - "name" to ObjectData(string = "Alice"), - "age" to ObjectData(number = 30.0), - "isActive" to ObjectData(boolean = true) - ) - ) - restObjects.setMapRef(channelName, "root", "testMap", testMapObjectId) - - // wait for updated testMap to be available in the root map - assertWaiter { rootMap.get("testMap") != null } - - // Assert initial state after creation - val testMap = rootMap.get("testMap")?.asLiveMap - assertNotNull(testMap, "Test map should be created and accessible") - assertEquals(3L, testMap.size(), "Test map should have 3 initial entries") - assertEquals("Alice", testMap.get("name")?.asString, "Initial name should be Alice") - assertEquals(30.0, testMap.get("age")?.asNumber, "Initial age should be 30") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Initial active status should be true") - - // Step 2: Update an existing field (name from "Alice" to "Bob") - restObjects.setMapValue(channelName, testMapObjectId, "name", ObjectData(string = "Bob")) - // Wait for the map to be updated - assertWaiter { testMap.get("name")?.asString == "Bob" } - - // Assert after updating existing field - assertEquals(3L, testMap.size(), "Map size should remain the same after update") - assertEquals("Bob", testMap.get("name")?.asString, "Name should be updated to Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - - // Step 3: Add a new field (email) - restObjects.setMapValue(channelName, testMapObjectId, "email", ObjectData(string = "bob@example.com")) - // Wait for the map to be updated - assertWaiter { testMap.get("email")?.asString == "bob@example.com" } - - // Assert after adding new field - assertEquals(4L, testMap.size(), "Map size should increase after adding new field") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should be added successfully") - - // Step 4: Add another new field with different data type (score as number) - restObjects.setMapValue(channelName, testMapObjectId, "score", ObjectData(number = 85.0)) - // Wait for the map to be updated - assertWaiter { testMap.get("score")?.asNumber == 85.0 } - - // Assert after adding second new field - assertEquals(5L, testMap.size(), "Map size should increase to 5 after adding score") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should be added as numeric value") - - // Step 5: Update the boolean field - restObjects.setMapValue(channelName, testMapObjectId, "isActive", ObjectData(boolean = false)) - // Wait for the map to be updated - assertWaiter { testMap.get("isActive")?.asBoolean == false } - - // Assert after updating boolean field - assertEquals(5L, testMap.size(), "Map size should remain 5 after boolean update") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should be updated to false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") - - // Step 6: Remove a field (age) - restObjects.removeMapValue(channelName, testMapObjectId, "age") - // Wait for the map to be updated - assertWaiter { testMap.get("age") == null } - - // Assert after removing field - assertEquals(4L, testMap.size(), "Map size should decrease to 4 after removing age") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertNull(testMap.get("age"), "Age should be removed and return null") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") - - // Step 7: Remove another field (score) - restObjects.removeMapValue(channelName, testMapObjectId, "score") - // Wait for the map to be updated - assertWaiter { testMap.get("score") == null } - - // Assert final state after second removal - assertEquals(3L, testMap.size(), "Map size should decrease to 3 after removing score") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertNull(testMap.get("score"), "Score should be removed and return null") - assertNull(testMap.get("age"), "Age should remain null") - - // Final verification - ensure all expected keys exist and unwanted keys don't - assertEquals(3, testMap.size(), "Final map should have exactly 3 entries") - - val finalKeys = testMap.keys().toSet() - assertEquals(setOf("name", "isActive", "email"), finalKeys, "Final keys should match expected set") - - val finalValues = testMap.values().map { it.value }.toSet() - assertEquals(setOf("Bob", false, "bob@example.com"), finalValues, "Final string values should match expected set") - } - - /** - * Tests sequential map operations including creation with initial data, updating existing fields, - * adding new fields, and removing fields. Validates the resulting data after each operation. - */ - @Test - fun testLiveMapOperationsUsingRealtime() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val objects = channel.objects - val rootMap = channel.objects.root - - // Step 1: Create a new map with initial data - val testMapObject = objects.createMap( - mapOf( - "name" to LiveMapValue.of("Alice"), - "age" to LiveMapValue.of(30), - "isActive" to LiveMapValue.of(true), - ) - ) - rootMap.set("testMap", LiveMapValue.of(testMapObject)) - - // wait for updated testMap to be available in the root map - assertWaiter { rootMap.get("testMap") != null } - - // Assert initial state after creation - val testMap = rootMap.get("testMap")?.asLiveMap - assertNotNull(testMap, "Test map should be created and accessible") - assertEquals(3L, testMap.size(), "Test map should have 3 initial entries") - assertEquals("Alice", testMap.get("name")?.asString, "Initial name should be Alice") - assertEquals(30.0, testMap.get("age")?.asNumber, "Initial age should be 30") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Initial active status should be true") - - // Step 2: Update an existing field (name from "Alice" to "Bob") - testMap.set("name", LiveMapValue.of("Bob")) - // Wait for the map to be updated - assertWaiter { testMap.get("name")?.asString == "Bob" } - - // Assert after updating existing field - assertEquals(3L, testMap.size(), "Map size should remain the same after update") - assertEquals("Bob", testMap.get("name")?.asString, "Name should be updated to Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - - // Step 3: Add a new field (email) - testMap.set("email", LiveMapValue.of("bob@example.com")) - // Wait for the map to be updated - assertWaiter { testMap.get("email")?.asString == "bob@example.com" } - - // Assert after adding new field - assertEquals(4L, testMap.size(), "Map size should increase after adding new field") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should be added successfully") - - // Step 4: Add another new field with different data type (score as number) - testMap.set("score", LiveMapValue.of(85)) - // Wait for the map to be updated - assertWaiter { testMap.get("score")?.asNumber == 85.0 } - - // Assert after adding second new field - assertEquals(5L, testMap.size(), "Map size should increase to 5 after adding score") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should be added as numeric value") - - // Step 5: Update the boolean field - testMap.set("isActive", LiveMapValue.of(false)) - // Wait for the map to be updated - assertWaiter { testMap.get("isActive")?.asBoolean == false } - - // Assert after updating boolean field - assertEquals(5L, testMap.size(), "Map size should remain 5 after boolean update") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should be updated to false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") - - // Step 6: Remove a field (age) - testMap.remove("age") - // Wait for the map to be updated - assertWaiter { testMap.get("age") == null } - - // Assert after removing field - assertEquals(4L, testMap.size(), "Map size should decrease to 4 after removing age") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertNull(testMap.get("age"), "Age should be removed and return null") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") - - // Step 7: Remove another field (score) - testMap.remove("score") - // Wait for the map to be updated - assertWaiter { testMap.get("score") == null } - - // Assert final state after second removal - assertEquals(3L, testMap.size(), "Map size should decrease to 3 after removing score") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertNull(testMap.get("score"), "Score should be removed and return null") - assertNull(testMap.get("age"), "Age should remain null") - - // Final verification - ensure all expected keys exist and unwanted keys don't - assertEquals(3, testMap.size(), "Final map should have exactly 3 entries") - - val finalKeys = testMap.keys().toSet() - assertEquals(setOf("name", "isActive", "email"), finalKeys, "Final keys should match expected set") - - val finalValues = testMap.values().map { it.value }.toSet() - assertEquals(setOf("Bob", false, "bob@example.com"), finalValues, "Final string values should match expected set") - } - - @Test - fun testLiveMapChangesUsingSubscription() = runTest { - val channelName = generateChannelName() - val userProfileObjectId = restObjects.createUserProfileMapObject(channelName) - restObjects.setMapRef(channelName, "root", "userProfile", userProfileObjectId) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - // Get the user profile map object from the root map - val userProfile = rootMap.get("userProfile")?.asLiveMap - assertNotNull(userProfile, "User profile should be synchronized") - assertEquals(4L, userProfile.size(), "User profile should contain 4 entries") - - // Verify initial values - assertEquals("user123", userProfile.get("userId")?.asString, "Initial userId should be user123") - assertEquals("John Doe", userProfile.get("name")?.asString, "Initial name should be John Doe") - assertEquals("john@example.com", userProfile.get("email")?.asString, "Initial email should be john@example.com") - assertEquals(true, userProfile.get("isActive")?.asBoolean, "Initial isActive should be true") - - // Subscribe to changes in the user profile map - val userProfileUpdates = mutableListOf() - val userProfileSubscription = userProfile.subscribe { update -> userProfileUpdates.add(update) } - - // Step 1: Update an existing field in the user profile map (change the name) - restObjects.setMapValue(channelName, userProfileObjectId, "name", ObjectData(string = "Bob Smith")) - - // Wait for the update to be received - assertWaiter { userProfileUpdates.isNotEmpty() } - - // Verify the update was received - assertEquals(1, userProfileUpdates.size, "Should receive one update") - val firstUpdateMap = userProfileUpdates.first().update - assertEquals(1, firstUpdateMap.size, "Should have one key change") - assertTrue(firstUpdateMap.containsKey("name"), "Update should contain name key") - assertEquals(LiveMapUpdate.Change.UPDATED, firstUpdateMap["name"], "name should be marked as UPDATED") - - // Verify the value was actually updated - assertEquals("Bob Smith", userProfile.get("name")?.asString, "Name should be updated to Bob Smith") - - // Step 2: Update another field in the user profile map (change the email) - userProfileUpdates.clear() - restObjects.setMapValue(channelName, userProfileObjectId, "email", ObjectData(string = "bob@example.com")) - - // Wait for the second update - assertWaiter { userProfileUpdates.isNotEmpty() } - - // Verify the second update - assertEquals(1, userProfileUpdates.size, "Should receive one update for the second change") - val secondUpdateMap = userProfileUpdates.first().update - assertEquals(1, secondUpdateMap.size, "Should have one key change") - assertTrue(secondUpdateMap.containsKey("email"), "Update should contain email key") - assertEquals(LiveMapUpdate.Change.UPDATED, secondUpdateMap["email"], "email should be marked as UPDATED") - - // Verify the value was actually updated - assertEquals("bob@example.com", userProfile.get("email")?.asString, "Email should be updated to bob@example.com") - - // Step 3: Remove an existing field from the user profile map (remove isActive) - userProfileUpdates.clear() - restObjects.removeMapValue(channelName, userProfileObjectId, "isActive") - - // Wait for the removal update - assertWaiter { userProfileUpdates.isNotEmpty() } - - // Verify the removal update - assertEquals(1, userProfileUpdates.size, "Should receive one update for removal") - val removalUpdateMap = userProfileUpdates.first().update - assertEquals(1, removalUpdateMap.size, "Should have one key change") - assertTrue(removalUpdateMap.containsKey("isActive"), "Update should contain isActive key") - assertEquals(LiveMapUpdate.Change.REMOVED, removalUpdateMap["isActive"], "isActive should be marked as REMOVED") - - // Verify final state of the user profile map - assertEquals(3L, userProfile.size(), "User profile should have 3 entries after removing isActive") - assertEquals("user123", userProfile.get("userId")?.asString, "userId should remain unchanged") - assertEquals("Bob Smith", userProfile.get("name")?.asString, "name should remain updated") - assertEquals("bob@example.com", userProfile.get("email")?.asString, "email should remain updated") - assertNull(userProfile.get("isActive"), "isActive should be removed") - - // Clean up subscription - userProfileUpdates.clear() - userProfileSubscription.unsubscribe() - // No updates should be received after unsubscribing - restObjects.setMapValue(channelName, userProfileObjectId, "country", ObjectData(string = "uk")) - - // Wait for a moment to ensure no updates are received - assertWaiter { userProfile.size() == 4L } - - assertTrue(userProfileUpdates.isEmpty(), "No updates should be received after unsubscribing") - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultRealtimeObjectsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultRealtimeObjectsTest.kt deleted file mode 100644 index 428fed56a..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultRealtimeObjectsTest.kt +++ /dev/null @@ -1,256 +0,0 @@ -package io.ably.lib.objects.integration - -import io.ably.lib.objects.* -import io.ably.lib.objects.integration.helpers.State -import io.ably.lib.objects.integration.helpers.fixtures.initializeRootMap -import io.ably.lib.objects.integration.helpers.simulateObjectDelete -import io.ably.lib.objects.integration.setup.IntegrationTest -import io.ably.lib.objects.state.ObjectsStateEvent -import io.ably.lib.objects.type.ObjectLifecycleEvent -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.type.map.LiveMapUpdate -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.text.toByteArray - -class DefaultRealtimeObjectsTest : IntegrationTest() { - - @Test - fun testChannelObjects() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val objects = channel.objects - assertNotNull(objects) - } - - @Test - fun testObjectsSyncEvents() = runTest { - val channelName = generateChannelName() - // Initialize the root map on the channel with initial data - restObjects.initializeRootMap(channelName) - - val channel = getRealtimeChannel(channelName) - val objects = channel.objects - assertNotNull(objects) - - assertEquals(ObjectsState.Initialized, objects.State, "Initial state should be INITIALIZED") - - val syncStates = mutableListOf() - objects.on(ObjectsStateEvent.SYNCING) { - syncStates.add(it) - } - objects.on(ObjectsStateEvent.SYNCED) { - syncStates.add(it) - } - - channel.attach() - - assertWaiter { syncStates.size == 2 } // Wait for both SYNCING and SYNCED events - - assertEquals(ObjectsStateEvent.SYNCING, syncStates[0], "First event should be SYNCING") - assertEquals(ObjectsStateEvent.SYNCED, syncStates[1], "Second event should be SYNCED") - - val rootMap = objects.root - assertEquals(6, rootMap.size(), "Root map should have 6 entries after sync") - } - - /** - * This will test objects sync process when the root map is initialized before channel attach. - * This includes checking the initial values of counters, maps, and other data types. - */ - @Test - fun testObjectsSync() = runTest { - val channelName = generateChannelName() - // Initialize the root map on the channel with initial data - restObjects.initializeRootMap(channelName) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - assertNotNull(rootMap) - - // Assert Counter Objects - // Test emptyCounter - should have initial value of 0 - val emptyCounter = rootMap.get("emptyCounter")?.asLiveCounter - assertNotNull(emptyCounter) - assertEquals(0.0, emptyCounter.value()) - - // Test initialValueCounter - should have initial value of 10 - val initialValueCounter = rootMap.get("initialValueCounter")?.asLiveCounter - assertNotNull(initialValueCounter) - assertEquals(10.0, initialValueCounter.value()) - - // Test referencedCounter - should have initial value of 20 - val referencedCounter = rootMap.get("referencedCounter")?.asLiveCounter - assertNotNull(referencedCounter) - assertEquals(20.0, referencedCounter.value()) - - // Assert Map Objects - // Test emptyMap - should be an empty map - val emptyMap = rootMap.get("emptyMap")?.asLiveMap - assertNotNull(emptyMap) - assertEquals(0L, emptyMap.size()) - - // Test referencedMap - should contain one key "counterKey" pointing to referencedCounter - val referencedMap = rootMap.get("referencedMap")?.asLiveMap - assertNotNull(referencedMap) - assertEquals(1L, referencedMap.size()) - val referencedMapCounter = referencedMap.get("counterKey")?.asLiveCounter - assertNotNull(referencedMapCounter) - assertEquals(20.0, referencedMapCounter.value()) // Should point to the same counter with value 20 - - // Test valuesMap - should contain all primitive data types and one map reference - val valuesMap = rootMap.get("valuesMap")?.asLiveMap - assertNotNull(valuesMap) - assertEquals(13L, valuesMap.size()) // Should have 13 entries - - // Assert string values - assertEquals("stringValue", valuesMap.get("string")?.asString) - assertEquals("", valuesMap.get("emptyString")?.asString) - - // Assert binary values - val bytesValue = valuesMap.get("bytes")?.asBinary - assertNotNull(bytesValue) - val expectedBinary = "eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9".toByteArray() - assertTrue(expectedBinary.contentEquals(bytesValue)) // Should contain encoded JSON data - - val emptyBytesValue = valuesMap.get("emptyBytes")?.asBinary - assertNotNull(emptyBytesValue) - assertEquals(0, emptyBytesValue.size) // Should be empty byte array - - // Assert numeric values - assertEquals(99999999.0, valuesMap.get("maxSafeNumber")?.asNumber) - assertEquals(-99999999.0, valuesMap.get("negativeMaxSafeNumber")?.asNumber) - assertEquals(1.0, valuesMap.get("number")?.asNumber) - assertEquals(0.0, valuesMap.get("zero")?.asNumber) - - // Assert boolean values - assertEquals(true, valuesMap.get("true")?.asBoolean) - assertEquals(false, valuesMap.get("false")?.asBoolean) - - // Assert JSON object value - should contain {"foo": "bar"} - val jsonObjectValue = valuesMap.get("object")?.asJsonObject - assertNotNull(jsonObjectValue) - assertEquals("bar", jsonObjectValue.get("foo").asString) - - // Assert JSON array value - should contain ["foo", "bar", "baz"] - val jsonArrayValue = valuesMap.get("array")?.asJsonArray - assertNotNull(jsonArrayValue) - assertEquals(3, jsonArrayValue.size()) - assertEquals("foo", jsonArrayValue[0].asString) - assertEquals("bar", jsonArrayValue[1].asString) - assertEquals("baz", jsonArrayValue[2].asString) - - // Assert map reference - should point to the same referencedMap - val mapRefValue = valuesMap.get("mapRef")?.asLiveMap - assertNotNull(mapRefValue) - assertEquals(1L, mapRefValue.size()) - val mapRefCounter = mapRefValue.get("counterKey")?.asLiveCounter - assertNotNull(mapRefCounter) - assertEquals(20.0, mapRefCounter.value()) // Should point to the same counter with value 20 - } - - /** - * Server runs periodic garbage collection (GC) to remove orphaned objects and will send - * OBJECT_DELETE events for objects that are no longer referenced. - * So, we simulate the deletion of an object by sending an object delete ProtocolMessage. - * This does not actually delete the object from the server, only simulates the deletion locally. - * Spec: RTLO4e - */ - @Test - fun testObjectDelete() = runTest { - val channelName = generateChannelName() - // Initialize the root map on the channel with initial data - restObjects.initializeRootMap(channelName) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - assertEquals(6L, rootMap.size()) // Should have 6 entries initially - - // Collection to track all lifecycle events - val lifecycleEvents = mutableListOf() - - // Remove the "referencedCounter" from the root map - val refCounter = rootMap.get("referencedCounter")?.asLiveCounter - assertNotNull(refCounter) - // Subscribe to counter updates to verify removal - val counterUpdates = mutableListOf() - refCounter.subscribe { event -> - counterUpdates.add(event.update.amount) - } - // Subscribe to lifecycle events for this counter - refCounter.on(ObjectLifecycleEvent.DELETED) { event -> - lifecycleEvents.add(event) - } - - // Simulate the deletion of the referencedCounter object - channel.objects.simulateObjectDelete(refCounter as DefaultLiveCounter) - - assertWaiter { rootMap.size() == 5L } // Wait for the removal to complete - assertNull(rootMap.get("referencedCounter")) // Should be null after removal - assertEquals(1, counterUpdates.size) // Should have received one update for deletion - assertEquals(-20.0, counterUpdates[0]) // The update should indicate counter was removed with value 20 - - // Remove the "referencedMap" from the root map - val referencedMap = rootMap.get("referencedMap")?.asLiveMap - assertNotNull(referencedMap) - // Subscribe to map updates to verify removal - val mapUpdates = mutableListOf>() - referencedMap.subscribe { event -> - mapUpdates.add(event.update) - } - // Subscribe to lifecycle events for this map - referencedMap.on(ObjectLifecycleEvent.DELETED) { event -> - lifecycleEvents.add(event) - } - - // Simulate the deletion of the referencedMap object - channel.objects.simulateObjectDelete(referencedMap as DefaultLiveMap) - - assertWaiter { rootMap.size() == 4L } // Wait for the removal to complete - assertNull(rootMap.get("referencedMap")) // Should be null after removal - assertEquals(1, mapUpdates.size) // Should have received one update for deletion - - val updatedMap = mapUpdates.first() - assertEquals(1, updatedMap.size) // Should have one change - assertEquals("counterKey", updatedMap.keys.first()) // The change should be for the "counterKey" - assertEquals(LiveMapUpdate.Change.REMOVED, updatedMap.values.first()) // Should indicate removal - - // Remove the "valuesMap" from the root map - val valuesMap = rootMap.get("valuesMap")?.asLiveMap - assertNotNull(valuesMap) - // Subscribe to map updates to verify removal - val valuesMapUpdates = mutableListOf>() - valuesMap.subscribe { event -> - valuesMapUpdates.add(event.update) - } - // Subscribe to lifecycle events for this map - valuesMap.on(ObjectLifecycleEvent.DELETED) { event -> - lifecycleEvents.add(event) - } - - // Simulate the deletion of the valuesMap object - channel.objects.simulateObjectDelete(valuesMap as DefaultLiveMap) - - assertWaiter { rootMap.size() == 3L } // Wait for the removal to complete - assertNull(rootMap.get("valuesMap")) // Should be null after removal - assertEquals(1, valuesMapUpdates.size) // Should have received one update for deletion - - val updatedValuesMap = valuesMapUpdates.first() - assertEquals(13, updatedValuesMap.size) // Should have 13 changes (one for each entry in valuesMap) - // Verify that all entries in valuesMap were marked as REMOVED - updatedValuesMap.values.forEach { change -> - assertEquals(LiveMapUpdate.Change.REMOVED, change) - } - - // Assert lifecycle events - assertEquals(3, lifecycleEvents.size) // Should have received 3 DELETED lifecycle events - lifecycleEvents.forEach { event -> - assertEquals(ObjectLifecycleEvent.DELETED, event) // All events should be DELETED - } - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt deleted file mode 100644 index 05b50b7dc..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt +++ /dev/null @@ -1,40 +0,0 @@ -package io.ably.lib.objects.integration.helpers - -import io.ably.lib.objects.* -import io.ably.lib.objects.DefaultRealtimeObjects -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.counter.LiveCounter -import io.ably.lib.objects.type.map.LiveMap -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.types.ProtocolMessage - -internal val LiveMap.ObjectId get() = (this as DefaultLiveMap).objectId - -internal val LiveCounter.ObjectId get() = (this as DefaultLiveCounter).objectId - -internal val RealtimeObjects.State get() = (this as DefaultRealtimeObjects).state - -/** - * Server runs periodic garbage collection (GC) to remove orphaned objects and will send - * OBJECT_DELETE events for objects that are no longer referenced. - * So, we simulate the deletion of an object by sending a ProtocolMessage. - */ -internal fun RealtimeObjects.simulateObjectDelete(baseObject: BaseRealtimeObject) { - val defaultRealtimeObjects = this as DefaultRealtimeObjects - val existingSiteCode = baseObject.siteTimeserials.keys.first() - val existingSiteSerial = baseObject.siteTimeserials[existingSiteCode]!! - - val deleteObjectProtoMsg = ProtocolMessage(ProtocolMessage.Action.`object`, channelName) - deleteObjectProtoMsg.state = arrayOf(ObjectMessage( - siteCode = existingSiteCode, - serial = existingSiteSerial + "1", // Increment serial to accept new operation - operation = ObjectOperation( - action = ObjectOperationAction.ObjectDelete, - objectId = baseObject.objectId, - ) - )) - defaultRealtimeObjects.handle(deleteObjectProtoMsg) -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt deleted file mode 100644 index d8eaaf697..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt +++ /dev/null @@ -1,75 +0,0 @@ -package io.ably.lib.objects.unit - -import io.ably.lib.objects.ObjectId -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.types.AblyException -import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows -import org.junit.Test -import kotlin.test.assertTrue - -class ObjectIdTest { - - @Test - fun testValidMapObjectId() { - val objectIdString = "map:abc123@1640995200000" - val objectId = ObjectId.fromString(objectIdString) - - assertEquals(ObjectType.Map, objectId.type) - assertEquals("map:abc123@1640995200000", objectId.toString()) - } - - @Test - fun testValidCounterObjectId() { - val objectIdString = "counter:def456@1640995200000" - val objectId = ObjectId.fromString(objectIdString) - - assertEquals(ObjectType.Counter, objectId.type) - assertEquals("counter:def456@1640995200000", objectId.toString()) - } - - @Test - fun testInvalidObjectType() { - val exception = assertThrows(AblyException::class.java) { - ObjectId.fromString("invalid:abc123@1640995200000") - } - assertAblyExceptionError(exception) - } - - @Test - fun testEmptyObjectId() { - val exception1 = assertThrows(AblyException::class.java) { - ObjectId.fromString("") - } - assertAblyExceptionError(exception1) - } - - private fun assertAblyExceptionError( - exception: AblyException - ) { - assertTrue(exception.errorInfo?.message?.contains("Invalid object id:") == true || - exception.errorInfo?.message?.contains("Invalid object type in object id:") == true) - assertEquals(92_000, exception.errorInfo?.code) - assertEquals(500, exception.errorInfo?.statusCode) - } - - @Test - fun testFromInitialValue() { - val objectType = ObjectType.Map - val initialValue = "test-value" - val nonce = "test-nonce" - val msTimestamp = 1640995200000L - - val objectId = ObjectId.fromInitialValue(objectType, initialValue, nonce, msTimestamp) - // Verify the string format follows the expected pattern: type:hash@timestamp - val objectIdString = objectId.toString() - assertTrue(objectIdString.startsWith("map:")) - assertTrue(objectIdString.contains("@")) - assertTrue(objectIdString.endsWith(msTimestamp.toString())) - - val expectedHash = "GSjv-adTaJPL8-382qF3JuIyE4TCc6QKIIqb577pz00" - // Verify the hash value matches expected - val hashPart = objectIdString.substring(4, objectIdString.indexOf("@")) - assertEquals(expectedHash, hashPart) - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt deleted file mode 100644 index 3f63a2d82..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package io.ably.lib.objects.unit - -import io.ably.lib.objects.ObjectsSyncTracker -import org.junit.Test -import org.junit.Assert.* - -class ObjectsSyncTrackerTest { - - @Test - fun `(RTO5a, RTO5a1, RTO5a2) Should parse valid sync channel serial with syncId and cursor`() { - val syncTracker = ObjectsSyncTracker("sync-123:cursor-456") - - assertEquals("sync-123", syncTracker.syncId) - assertFalse(syncTracker.hasSyncStarted("sync-123")) - assertTrue(syncTracker.hasSyncStarted(null)) - assertTrue(syncTracker.hasSyncStarted("sync-124")) - - assertEquals("cursor-456", syncTracker.syncCursor) - assertFalse(syncTracker.hasSyncEnded()) - } - - @Test - fun `(RTO5a5) Should handle null sync channel serial`() { - val syncTracker = ObjectsSyncTracker(null) - - assertNull(syncTracker.syncId) - assertTrue(syncTracker.hasSyncStarted(null)) - - assertNull(syncTracker.syncCursor) - assertTrue(syncTracker.hasSyncEnded()) - } - - @Test - fun `(RTO5a5) Should handle empty sync channel serial`() { - val syncTracker = ObjectsSyncTracker("") - - assertNull(syncTracker.syncId) - assertTrue(syncTracker.hasSyncStarted(null)) - - assertNull(syncTracker.syncCursor) - assertTrue(syncTracker.hasSyncEnded()) - } - - @Test - fun `should handle sync channel serial with special characters`() { - val syncTracker = ObjectsSyncTracker("sync_123-456:cursor_789-012") - - assertEquals("sync_123-456", syncTracker.syncId) - - assertEquals("cursor_789-012", syncTracker.syncCursor) - assertFalse(syncTracker.hasSyncEnded()) - } - - @Test - fun `(RTO5a4) should detect sync sequence ended when sync cursor is empty`() { - val syncTracker = ObjectsSyncTracker("sync-123:") - - assertEquals("sync-123", syncTracker.syncId) - assertTrue(syncTracker.hasSyncStarted(null)) - assertTrue(syncTracker.hasSyncStarted("")) - - assertEquals("", syncTracker.syncCursor) - assertTrue(syncTracker.hasSyncEnded()) - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt deleted file mode 100644 index ec8824e1a..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.ably.lib.objects.unit - -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertNotNull - -class RealtimeObjectsTest { - @Test - fun testChannelObjectGetterTest() = runTest { - val channel = getMockRealtimeChannel("test-channel") - val objects = channel.objects - assertNotNull(objects) - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt deleted file mode 100644 index 17be76951..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt +++ /dev/null @@ -1,169 +0,0 @@ -package io.ably.lib.objects.unit - -import io.ably.lib.objects.* -import io.ably.lib.objects.DefaultRealtimeObjects -import io.ably.lib.objects.ObjectsManager -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livecounter.LiveCounterManager -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.type.livemap.LiveMapManager -import io.ably.lib.realtime.AblyRealtime -import io.ably.lib.realtime.Channel -import io.ably.lib.realtime.ChannelState -import io.ably.lib.transport.ConnectionManager -import io.ably.lib.types.ChannelMode -import io.ably.lib.types.ChannelOptions -import io.ably.lib.types.ClientOptions -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.spyk -import kotlinx.coroutines.CompletableDeferred - -internal fun getMockRealtimeChannel( - channelName: String, - clientId: String = "client1", - channelModes: Array = arrayOf(ChannelMode.object_publish, ChannelMode.object_subscribe)): Channel { - val client = AblyRealtime(ClientOptions().apply { - autoConnect = false - key = "keyName:Value" - this.clientId = clientId - }) - val channelOpts = ChannelOptions().apply { modes = channelModes } - val channel = client.channels.get(channelName, channelOpts) - return spyk(channel) { - every { attach() } answers { - state = ChannelState.attached - } - every { detach() } answers { - state = ChannelState.detached - } - every { subscribe(any(), any()) } returns mockk(relaxUnitFun = true) - every { subscribe(any>(), any()) } returns mockk(relaxUnitFun = true) - every { subscribe(any()) } returns mockk(relaxUnitFun = true) - }.apply { - state = ChannelState.attached - } -} - -internal fun getMockObjectsAdapter(): ObjectsAdapter { - mockkStatic("io.ably.lib.objects.HelpersKt") - return mockk(relaxed = true) { - every { getChannel(any()) } returns getMockRealtimeChannel("testChannelName") - every { connectionManager } returns mockk(relaxed = true) - } -} - -internal fun getMockObjectsPool(): ObjectsPool { - return mockk(relaxed = true) -} - -internal fun ObjectsPool.size(): Int { - val pool = this.getPrivateField>("pool") - return pool.size -} - -internal val BaseRealtimeObject.TombstonedAt: Long? - get() = this.getPrivateField("tombstonedAt") - -/** - * ====================================== - * START - DefaultRealtimeObjects dep mocks - * ====================================== - */ -internal val ObjectsManager.SyncObjectsPool: Map - get() = this.getPrivateField("syncObjectsPool") - -internal val ObjectsManager.BufferedObjectOperations: List - get() = this.getPrivateField("bufferedObjectOperations") - -internal val ObjectsManager.SyncCompletionWaiter: CompletableDeferred? - get() = this.getPrivateField("syncCompletionWaiter") - -internal var DefaultRealtimeObjects.ObjectsManager: ObjectsManager - get() = this.getPrivateField("objectsManager") - set(value) = this.setPrivateField("objectsManager", value) - -internal var DefaultRealtimeObjects.ObjectsPool: ObjectsPool - get() = this.objectsPool - set(value) = this.setPrivateField("objectsPool", value) - -internal fun getDefaultRealtimeObjectsWithMockedDeps( - channelName: String = "testChannelName", - relaxed: Boolean = false -): DefaultRealtimeObjects { - val defaultRealtimeObjects = DefaultRealtimeObjects(channelName, getMockObjectsAdapter()) - // mock objectsPool to allow verification of method calls - if (relaxed) { - defaultRealtimeObjects.ObjectsPool = mockk(relaxed = true) - } else { - defaultRealtimeObjects.ObjectsPool = spyk(defaultRealtimeObjects.objectsPool, recordPrivateCalls = true) - } - // mock objectsManager to allow verification of method calls - if (relaxed) { - defaultRealtimeObjects.ObjectsManager = mockk(relaxed = true) - } else { - defaultRealtimeObjects.ObjectsManager = spyk(defaultRealtimeObjects.ObjectsManager, recordPrivateCalls = true) - } - return defaultRealtimeObjects -} -/** - * ====================================== - * END - DefaultRealtimeObjects dep mocks - * ====================================== - */ - -/** - * ====================================== - * START - DefaultLiveCounter dep mocks - * ====================================== - */ -internal var DefaultLiveCounter.LiveCounterManager: LiveCounterManager - get() = this.getPrivateField("liveCounterManager") - set(value) = this.setPrivateField("liveCounterManager", value) - -internal fun getDefaultLiveCounterWithMockedDeps( - objectId: String = "counter:testCounter@1", - relaxed: Boolean = false -): DefaultLiveCounter { - val defaultLiveCounter = DefaultLiveCounter.zeroValue(objectId, getDefaultRealtimeObjectsWithMockedDeps()) - if (relaxed) { - defaultLiveCounter.LiveCounterManager = mockk(relaxed = true) - } else { - defaultLiveCounter.LiveCounterManager = spyk(defaultLiveCounter.LiveCounterManager, recordPrivateCalls = true) - } - return defaultLiveCounter -} -/** - * ====================================== - * END - DefaultLiveCounter dep mocks - * ====================================== - */ - -/** - * ====================================== - * START - DefaultLiveMap dep mocks - * ====================================== - */ -internal var DefaultLiveMap.LiveMapManager: LiveMapManager - get() = this.getPrivateField("liveMapManager") - set(value) = this.setPrivateField("liveMapManager", value) - -internal fun getDefaultLiveMapWithMockedDeps( - objectId: String = "map:testMap@1", - relaxed: Boolean = false -): DefaultLiveMap { - val defaultLiveMap = DefaultLiveMap.zeroValue(objectId, getDefaultRealtimeObjectsWithMockedDeps()) - if (relaxed) { - defaultLiveMap.LiveMapManager = mockk(relaxed = true) - } else { - defaultLiveMap.LiveMapManager = spyk(defaultLiveMap.LiveMapManager, recordPrivateCalls = true) - } - return defaultLiveMap -} -/** - * ====================================== - * END - DefaultLiveMap dep mocks - * ====================================== - */ diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt deleted file mode 100644 index a6cd9bcf8..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt +++ /dev/null @@ -1,301 +0,0 @@ -package io.ably.lib.objects.unit - -import io.ably.lib.objects.* -import io.ably.lib.objects.assertWaiter -import io.ably.lib.types.AblyException -import io.ably.lib.types.ErrorInfo -import kotlinx.coroutines.* -import kotlinx.coroutines.test.* -import org.junit.Test -import org.junit.Assert.* -import java.util.concurrent.CancellationException - -class UtilsTest { - - @Test - fun testGenerateNonce() { - // Test basic functionality - val nonce1 = generateNonce() - val nonce2 = generateNonce() - - assertEquals(16, nonce1.length) - assertEquals(16, nonce2.length) - assertNotEquals(nonce1, nonce2) // Should be random - - // Test character set - val validChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - val nonce = generateNonce() - nonce.forEach { char -> - assertTrue("Nonce should only contain valid characters", validChars.contains(char)) - } - } - - @Test - fun testStringByteSize() { - // Test ASCII strings - assertEquals(5, "Hello".byteSize) - assertEquals(0, "".byteSize) - assertEquals(1, "A".byteSize) - - // Test non-ASCII strings - assertEquals(3, "你".byteSize) // Chinese character - assertEquals(4, "😊".byteSize) // Emoji - assertEquals(6, "你好".byteSize) // Two Chinese characters - } - - @Test - fun testErrorCreationFunctions() { - // Test clientError - val clientEx = clientError("Bad request") - assertEquals("Bad request", clientEx.errorInfo.message) - assertEquals(ErrorCode.BadRequest.code, clientEx.errorInfo.code) - assertEquals(HttpStatusCode.BadRequest.code, clientEx.errorInfo.statusCode) - - // Test serverError - val serverEx = serverError("Internal error") - assertEquals("Internal error", serverEx.errorInfo.message) - assertEquals(ErrorCode.InternalError.code, serverEx.errorInfo.code) - assertEquals(HttpStatusCode.InternalServerError.code, serverEx.errorInfo.statusCode) - - // Test objectError - val objectEx = objectError("Invalid object") - assertEquals("Invalid object", objectEx.errorInfo.message) - assertEquals(ErrorCode.InvalidObject.code, objectEx.errorInfo.code) - assertEquals(HttpStatusCode.InternalServerError.code, objectEx.errorInfo.statusCode) - - // Test objectError with cause - val cause = RuntimeException("Original error") - val objectExWithCause = objectError("Invalid object", cause) - assertEquals("Invalid object", objectExWithCause.errorInfo.message) - assertEquals(cause, objectExWithCause.cause) - } - - @Test - fun testAblyExceptionCreation() { - // Test with error message and codes - val ex = ablyException("Test error", ErrorCode.BadRequest, HttpStatusCode.BadRequest) - assertEquals("Test error", ex.errorInfo.message) - assertEquals(ErrorCode.BadRequest.code, ex.errorInfo.code) - assertEquals(HttpStatusCode.BadRequest.code, ex.errorInfo.statusCode) - - // Test with ErrorInfo - val errorInfo = ErrorInfo("Custom error", 400, 40000) - val ex2 = ablyException(errorInfo) - assertEquals("Custom error", ex2.errorInfo.message) - assertEquals(400, ex2.errorInfo.statusCode) - assertEquals(40000, ex2.errorInfo.code) - - // Test with cause - val cause = RuntimeException("Cause") - val ex3 = ablyException(errorInfo, cause) - assertEquals(cause, ex3.cause) - } - - @Test - fun testObjectsAsyncScopeLaunchWithCallback() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var callbackExecuted = false - var resultReceived: String? = null - - val callback = object : ObjectsCallback { - override fun onSuccess(result: String) { - callbackExecuted = true - resultReceived = result - } - - override fun onError(exception: AblyException) { - fail("Should not call onError for successful execution") - } - } - - asyncScope.launchWithCallback(callback) { - delay(10) // Simulate async work - "test result" - } - - // Wait for callback to be executed - assertWaiter { callbackExecuted } - - assertTrue("Callback should be executed", callbackExecuted) - assertEquals("test result", resultReceived) - } - - @Test - fun testObjectsAsyncScopeLaunchWithCallbackError() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var errorReceived: AblyException? = null - - val callback = object : ObjectsCallback { - override fun onSuccess(result: String) { - fail("Should not call onSuccess for error case") - } - - override fun onError(exception: AblyException) { - errorReceived = exception - } - } - - asyncScope.launchWithCallback(callback) { - delay(10) - throw AblyException.fromErrorInfo(ErrorInfo("Test error", 400, 40000)) - } - - // Wait for error to be received - assertWaiter { errorReceived != null } - - assertNotNull("Error should be received", errorReceived) - assertEquals("Test error", errorReceived?.errorInfo?.message) - assertEquals(400, errorReceived?.errorInfo?.statusCode) - } - - @Test - fun testObjectsAsyncScopeLaunchWithVoidCallback() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var callbackExecuted = false - - val callback = object : ObjectsCallback { - override fun onSuccess(result: Void?) { - callbackExecuted = true - } - - override fun onError(exception: AblyException) { - fail("Should not call onError for successful execution") - } - } - - asyncScope.launchWithVoidCallback(callback) { - delay(10) // Simulate async work - } - - // Wait for callback to be executed - assertWaiter { callbackExecuted } - - assertTrue("Callback should be executed", callbackExecuted) - } - - @Test - fun testObjectsAsyncScopeLaunchWithVoidCallbackError() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var errorReceived: AblyException? = null - - val callback = object : ObjectsCallback { - override fun onSuccess(result: Void?) { - fail("Should not call onSuccess for error case") - } - - override fun onError(exception: AblyException) { - errorReceived = exception - } - } - - asyncScope.launchWithVoidCallback(callback) { - delay(10) - throw AblyException.fromErrorInfo(ErrorInfo("Test error", 500, 50000)) - } - - // Wait for error to be received - assertWaiter { errorReceived != null } - - assertNotNull("Error should be received", errorReceived) - assertEquals("Test error", errorReceived?.errorInfo?.message) - assertEquals(500, errorReceived?.errorInfo?.statusCode) - } - - @Test - fun testObjectsAsyncScopeCallbackExceptionHandling() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var callback1Called = false - var callback2Called = false - - val callback1 = object : ObjectsCallback { - override fun onSuccess(result: String) { - callback1Called = true - throw RuntimeException("Callback exception") - } - - override fun onError(exception: AblyException) { - fail("Should not call onError when onSuccess throws") - } - } - - asyncScope.launchWithCallback(callback1) { "test result" } - // Wait for callback to be called - assertWaiter { callback1Called } - - val callback2 = object : ObjectsCallback { - override fun onSuccess(result: String) { - callback2Called = true - } - - override fun onError(exception: AblyException) { - fail("Should not call onError when onSuccess throws") - } - } - - asyncScope.launchWithCallback(callback2) { "test result" } - // Callback 2 should be called even if callback 1 throws an exception - assertWaiter { callback2Called } - } - - @Test - fun testObjectsAsyncScopeCancel() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var errorReceived = false - - val callback = object : ObjectsCallback { - override fun onSuccess(result: String) { - fail("Should not call onSuccess") - } - - override fun onError(exception: AblyException) { - errorReceived = true - } - } - - asyncScope.launchWithCallback(callback) { - delay(10000) // Long delay - "test result" - } - - // Cancel immediately - asyncScope.cancel(CancellationException("Test cancellation")) - - // Wait a bit to ensure cancellation takes effect - assertWaiter { errorReceived } - } - - @Test - fun testObjectsAsyncScopeNonAblyException() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var errorReceived = false - var error: AblyException? = null - - val callback = object : ObjectsCallback { - override fun onSuccess(result: String) { - fail("Should not call onSuccess for error case") - } - - override fun onError(exception: AblyException) { - errorReceived = true - error = exception - } - } - - asyncScope.launchWithCallback(callback) { - delay(10) - throw RuntimeException("Non-Ably exception") - } - - // Wait for error to be received - assertWaiter { errorReceived } - - // Non-Ably exceptions should be wrapped in AblyException - assertNotNull("Non-Ably exceptions should be wrapped in AblyException", error) - assertEquals("Error executing operation", error?.errorInfo?.message) - assertEquals(ErrorCode.BadRequest.code, error?.errorInfo?.code) - assertEquals(HttpStatusCode.BadRequest.code, error?.errorInfo?.statusCode) - - assertTrue(error?.cause is RuntimeException) - assertEquals("Non-Ably exception", error?.cause?.message) - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultRealtimeObjectsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultRealtimeObjectsTest.kt deleted file mode 100644 index 0a0ae9907..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultRealtimeObjectsTest.kt +++ /dev/null @@ -1,489 +0,0 @@ -package io.ably.lib.objects.unit.objects - -import io.ably.lib.objects.* -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.ObjectsState -import io.ably.lib.objects.ROOT_OBJECT_ID -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.type.livemap.LiveMapEntry -import io.ably.lib.objects.unit.BufferedObjectOperations -import io.ably.lib.objects.unit.ObjectsManager -import io.ably.lib.objects.unit.SyncObjectsPool -import io.ably.lib.objects.unit.getMockObjectsAdapter -import io.ably.lib.objects.unit.getDefaultRealtimeObjectsWithMockedDeps -import io.ably.lib.objects.unit.getMockRealtimeChannel -import io.ably.lib.objects.unit.size -import io.ably.lib.realtime.ChannelState -import io.ably.lib.types.AblyException -import io.ably.lib.types.ErrorInfo -import io.ably.lib.types.ProtocolMessage -import io.mockk.every -import io.mockk.verify -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -class DefaultRealtimeObjectsTest { - - private val testInstances = mutableListOf() - - @After - fun tearDown() { - val cleanupError = AblyException.fromErrorInfo(ErrorInfo("test cleanup", 500)) - testInstances.forEach { it.dispose(cleanupError) } - testInstances.clear() - } - - @Test - fun `(RTO4, RTO4a) When channel ATTACHED with HAS_OBJECTS flag true should start sync sequence`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // RTO4a - If the HAS_OBJECTS flag is 1, the server will shortly perform an OBJECT_SYNC sequence - defaultRealtimeObjects.handleStateChange(ChannelState.attached, true) - - assertWaiter { defaultRealtimeObjects.state == ObjectsState.Syncing } - - // It is expected that the client will start a new sync sequence - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.startNewSync(null) - } - verify(exactly = 0) { - defaultRealtimeObjects.ObjectsManager.endSync() - } - } - - @Test - fun `(RTO4, RTO4b) When channel ATTACHED with HAS_OBJECTS flag false should complete sync immediately`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Set up some objects in objectPool that should be cleared - val rootObject = defaultRealtimeObjects.objectsPool.get(ROOT_OBJECT_ID) as DefaultLiveMap - rootObject.data["key1"] = LiveMapEntry(data = ObjectData("testValue1")) - defaultRealtimeObjects.objectsPool.set("counter:testObject@1", DefaultLiveCounter.zeroValue("counter:testObject@1", defaultRealtimeObjects)) - assertEquals(2, defaultRealtimeObjects.objectsPool.size(), "RTO4b - Should have 2 objects before state change") - - // RTO4b - If the HAS_OBJECTS flag is 0, the sync sequence must be considered complete immediately - defaultRealtimeObjects.handleStateChange(ChannelState.attached, false) - - // Verify expected outcomes - assertWaiter { defaultRealtimeObjects.state == ObjectsState.Synced } // RTO4b4 - - verify(exactly = 1) { - defaultRealtimeObjects.objectsPool.resetToInitialPool(true) - } - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.endSync() - } - - assertEquals(0, defaultRealtimeObjects.ObjectsManager.SyncObjectsPool.size) // RTO4b3 - assertEquals(0, defaultRealtimeObjects.ObjectsManager.BufferedObjectOperations.size) // RTO4d - assertEquals(1, defaultRealtimeObjects.objectsPool.size()) // RTO4b1 - Only root remains - assertEquals(rootObject, defaultRealtimeObjects.objectsPool.get(ROOT_OBJECT_ID)) // points to previously created root object - assertEquals(0, rootObject.data.size) // RTO4b2 - root object must be empty - } - - @Test - fun `(RTO4) When channel ATTACHED from INITIALIZED state should always start sync`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Ensure we're in INITIALIZED state - defaultRealtimeObjects.state = ObjectsState.Initialized - - // RTO4a - Should start sync even with HAS_OBJECTS flag false when in INITIALIZED state - defaultRealtimeObjects.handleStateChange(ChannelState.attached, false) - - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.startNewSync(null) - } - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.endSync() - } - } - - @Test - fun `(RTO5, RTO7) Should delegate OBJECT and OBJECT_SYNC protocolMessage to ObjectManager`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps(relaxed = true) - - // Create test ObjectMessage for OBJECT action - val objectMessage = ObjectMessage( - id = "testId", - timestamp = 1234567890L, - connectionId = "testConnectionId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:testObject@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "serial1", - siteCode = "site1" - ) - // Create ProtocolMessage with OBJECT action - val objectProtocolMessage = ProtocolMessage(ProtocolMessage.Action.`object`).apply { - id = "protocolId1" - channel = "testChannel" - channelSerial = "channelSerial1" - timestamp = 1234567890L - state = arrayOf(objectMessage) - } - // Test OBJECT action delegation - defaultRealtimeObjects.handle(objectProtocolMessage) - - // Verify that handleObjectMessages was called with the correct parameters - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.handleObjectMessages(listOf(objectMessage)) - } - - // Create test ObjectMessage for OBJECT_SYNC action - val objectSyncMessage = ObjectMessage( - id = "testSyncId", - timestamp = 1234567890L, - connectionId = "testSyncConnectionId", - objectState = ObjectState( - objectId = "map:testObject@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "syncSerial1"), - ), - serial = "syncSerial1", - siteCode = "site1" - ) - // Create ProtocolMessage with OBJECT_SYNC action - val objectSyncProtocolMessage = ProtocolMessage(ProtocolMessage.Action.object_sync).apply { - id = "protocolId2" - channel = "testChannel" - channelSerial = "syncChannelSerial1" - timestamp = 1234567890L - state = arrayOf(objectSyncMessage) - } - // Test OBJECT_SYNC action delegation - defaultRealtimeObjects.handle(objectSyncProtocolMessage) - // Verify that handleObjectSyncMessages was called with the correct parameters - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.handleObjectSyncMessages(listOf(objectSyncMessage), "syncChannelSerial1") - } - } - - @Test - fun `(RTO20e1) handleStateChange(DETACHED) fails pending ACK waiters with error 92008`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Capture the error passed to failBufferedAcks via a CompletableDeferred - val capturedError = CompletableDeferred() - every { defaultRealtimeObjects.ObjectsManager.failBufferedAcks(any()) } answers { - capturedError.complete(firstArg()) - callOriginal() - } - - defaultRealtimeObjects.handleStateChange(ChannelState.detached, false) - - val error = capturedError.await() - assertEquals(92008, error.errorInfo.code) // PublishAndApplyFailedDueToChannelState - } - - @Test - fun `(RTO20e1) handleStateChange(SUSPENDED) fails pending ACK waiters with error 92008`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - val capturedError = CompletableDeferred() - every { defaultRealtimeObjects.ObjectsManager.failBufferedAcks(any()) } answers { - capturedError.complete(firstArg()) - callOriginal() - } - - defaultRealtimeObjects.handleStateChange(ChannelState.suspended, false) - - val error = capturedError.await() - assertEquals(92008, error.errorInfo.code) // PublishAndApplyFailedDueToChannelState - } - - @Test - fun `(RTO20e1) handleStateChange(FAILED) fails pending ACK waiters and propagates channel reason`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Override the channel returned by the adapter to carry a non-null reason - val channelReason = ErrorInfo("channel failed due to auth error", 40100, 401) - val channelWithReason = getMockRealtimeChannel("testChannelName") - channelWithReason.reason = channelReason - every { defaultRealtimeObjects.adapter.getChannel(any()) } returns channelWithReason - - val capturedError = CompletableDeferred() - every { defaultRealtimeObjects.ObjectsManager.failBufferedAcks(any()) } answers { - capturedError.complete(firstArg()) - callOriginal() - } - - defaultRealtimeObjects.handleStateChange(ChannelState.failed, false) - - val error = capturedError.await() - assertEquals(92008, error.errorInfo.code) - val causeException = error.cause as? AblyException - assertNotNull(causeException, "Error cause must include the channel's reason") - assertEquals(channelReason.code, causeException.errorInfo.code) - assertEquals(channelReason.message, causeException.errorInfo.message) - } - - @Test - fun `(RTO4) handleStateChange(SUSPENDED) does NOT clear objects data`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Use the failBufferedAcks call as a signal that the state-change coroutine has run to completion - val failCalled = CompletableDeferred() - every { defaultRealtimeObjects.ObjectsManager.failBufferedAcks(any()) } answers { - callOriginal() - failCalled.complete(Unit) - } - - defaultRealtimeObjects.handleStateChange(ChannelState.suspended, false) - - // For SUSPENDED, the coroutine ends immediately after failBufferedAcks (no clear calls) - failCalled.await() - - verify(exactly = 0) { defaultRealtimeObjects.objectsPool.clearObjectsData(any()) } - verify(exactly = 0) { defaultRealtimeObjects.ObjectsManager.clearSyncObjectsPool() } - } - - @Test - fun `(RTO4) handleStateChange(DETACHED) clears objects data and sync pool`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Use clearSyncObjectsPool (the last operation in the coroutine) as the completion signal - val syncPoolCleared = CompletableDeferred() - every { defaultRealtimeObjects.ObjectsManager.clearSyncObjectsPool() } answers { - callOriginal() - syncPoolCleared.complete(Unit) - } - - defaultRealtimeObjects.handleStateChange(ChannelState.detached, false) - - syncPoolCleared.await() - - verify(exactly = 1) { defaultRealtimeObjects.objectsPool.clearObjectsData(false) } - verify(exactly = 1) { defaultRealtimeObjects.ObjectsManager.clearSyncObjectsPool() } - } - - @Test - fun `(RTO4d) ATTACHED with hasObjects=true still clears bufferedObjectOperations`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - val manager = defaultRealtimeObjects.ObjectsManager - - // Pre-populate bufferedObjectOperations with a dummy operation - @Suppress("UNCHECKED_CAST") - (manager.BufferedObjectOperations as MutableList).add( - ObjectMessage( - id = "pre-attach-op", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ) - ) - ) - assertEquals(1, manager.BufferedObjectOperations.size) - - // ATTACHED with hasObjects=true — RTO4d must clear the buffer before starting sync - defaultRealtimeObjects.handleStateChange(ChannelState.attached, true) - - assertWaiter { defaultRealtimeObjects.state == ObjectsState.Syncing } - assertEquals(0, manager.BufferedObjectOperations.size, "RTO4d - buffer must be cleared unconditionally on ATTACHED") - } - - @Test - fun `(RTO4d) Pre-ATTACHED buffered operations are discarded, not applied after sync`() = runTest { - val defaultRealtimeObjects = DefaultRealtimeObjects("testChannel", getMockObjectsAdapter()) - .also { testInstances.add(it) } - - // Set up a counter in the pool - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - // Pre-populate bufferedObjectOperations with a COUNTER_INC — simulates an op received before ATTACHED - @Suppress("UNCHECKED_CAST") - (objectsManager.BufferedObjectOperations as MutableList).add( - ObjectMessage( - id = "pre-attach-inc", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ) - ) - ) - assertEquals(1, objectsManager.BufferedObjectOperations.size) - - // ATTACHED with hasObjects=true: RTO4d clears the buffer, then starts sync - defaultRealtimeObjects.handleStateChange(ChannelState.attached, true) - assertWaiter { defaultRealtimeObjects.state == ObjectsState.Syncing } - assertEquals(0, objectsManager.BufferedObjectOperations.size, "buffer must be cleared by RTO4d") - - // Complete sync by calling handleObjectSyncMessages directly (sequentialScope is idle now) - objectsManager.handleObjectSyncMessages( - listOf( - ObjectMessage( - id = "sync-msg-1", - objectState = ObjectState( - objectId = "counter:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - counter = ObjectsCounter(count = 0.0) - ) - ) - ), - "sync-id:" // empty cursor — ends sync (RTO5a4) - ) - - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state) - - // The pre-ATTACHED COUNTER_INC was discarded — counter should remain at 0 - assertEquals(0.0, counter.data.get(), "RTO4d - pre-ATTACHED buffered op must be discarded, not applied after sync") - } - - @Test - fun `(RTO5a2b removed) Buffered operations survive a server-initiated resync (new OBJECT_SYNC without ATTACHED)`() { - val defaultRealtimeObjects = DefaultRealtimeObjects("testChannel", getMockObjectsAdapter()) - .also { testInstances.add(it) } - - // Set up a counter in the pool - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(5.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - // sync-1 is in progress - objectsManager.startNewSync("sync-1") - assertEquals(ObjectsState.Syncing, defaultRealtimeObjects.state) - - // Buffer a COUNTER_INC during sync-1 - objectsManager.handleObjectMessages( - listOf( - ObjectMessage( - id = "channel-op-1", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 3.0) - ), - serial = "serial-op-1", - siteCode = "site1" - ) - ) - ) - assertEquals(1, objectsManager.BufferedObjectOperations.size, "op buffered during sync-1") - - // Server sends a new OBJECT_SYNC with a different sync-id — triggers startNewSync("sync-2") internally - // OLD behaviour (RTO5a2b): startNewSync would have cleared bufferedObjectOperations here - // NEW behaviour (RTO5a2b removed): buffer is preserved - objectsManager.handleObjectSyncMessages( - listOf( - ObjectMessage( - id = "sync2-msg-1", - objectState = ObjectState( - objectId = "counter:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "resync-serial"), - counter = ObjectsCounter(count = 5.0) - ) - ) - ), - "sync-2:cursor-1" // has cursor — not ending yet - ) - - assertEquals(1, objectsManager.BufferedObjectOperations.size, - "startNewSync must NOT clear bufferedObjectOperations (RTO5a2b removed)") - - // Complete sync-2 (ending serial, no new messages) - objectsManager.handleObjectSyncMessages(emptyList(), "sync-2:") - - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state) - // sync-2 restored counter to 5.0; buffered COUNTER_INC (+3.0) applied after sync → 8.0 - assertEquals(8.0, counter.data.get(), - "buffered COUNTER_INC from before server-initiated resync must be applied after sync completes") - } - - @Test - fun `(OM2) Populate objectMessage missing id, timestamp and connectionId from protocolMessage`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Capture the ObjectMessages that are passed to ObjectsManager methods - var capturedObjectMessages: List? = null - var capturedObjectSyncMessages: List? = null - - // Mock the ObjectsManager to capture the messages - defaultRealtimeObjects.ObjectsManager.apply { - every { handleObjectMessages(any>()) } answers { - capturedObjectMessages = firstArg() - } - every { handleObjectSyncMessages(any(), any()) } answers { - capturedObjectSyncMessages = firstArg() - } - } - - // Create ObjectMessage with missing fields (id, timestamp, connectionId) - val objectMessageWithMissingFields = ObjectMessage( - id = null, // OM2a - missing id - timestamp = null, // OM2e - missing timestamp - connectionId = null, // OM2c - missing connectionId - ) - - // Create ProtocolMessage with OBJECT action and populated fields - val objectProtocolMessage = ProtocolMessage(ProtocolMessage.Action.`object`).apply { - id = "protocolId1" - channel = "testChannel" - channelSerial = "channelSerial1" - connectionId = "protocolConnectionId" - timestamp = 1234567890L - state = arrayOf(objectMessageWithMissingFields) - } - - // Test OBJECT action - should populate missing fields - defaultRealtimeObjects.handle(objectProtocolMessage) - - // Verify that the captured ObjectMessage has populated fields - assertWaiter { capturedObjectMessages != null } - assertEquals(1, capturedObjectMessages!!.size) - - val populatedObjectMessage = capturedObjectMessages!![0] - assertEquals("protocolId1:0", populatedObjectMessage.id) // OM2a - id should be protocolId:index - assertEquals(1234567890L, populatedObjectMessage.timestamp) // OM2e - timestamp from protocol message - assertEquals("protocolConnectionId", populatedObjectMessage.connectionId) // OM2c - connectionId from protocol message - - - // Create ObjectMessage with missing fields for OBJECT_SYNC - val objectSyncMessageWithMissingFields = ObjectMessage( - id = null, // OM2a - missing id - timestamp = null, // OM2e - missing timestamp - connectionId = null, // OM2c - missing connectionId - ) - - // Create ProtocolMessage with OBJECT_SYNC action and populated fields - val objectSyncProtocolMessage = ProtocolMessage(ProtocolMessage.Action.object_sync).apply { - id = "protocolId2" - channel = "testChannel" - channelSerial = "syncChannelSerial1" - connectionId = "protocolConnectionId" - timestamp = 9876543210L - state = arrayOf(objectSyncMessageWithMissingFields) - } - - // Test OBJECT_SYNC action - should populate missing fields - defaultRealtimeObjects.handle(objectSyncProtocolMessage) - - // Verify that the captured ObjectMessage has populated fields - assertWaiter { capturedObjectSyncMessages != null } - assertEquals(1, capturedObjectSyncMessages!!.size) - - val populatedObjectSyncMessage = capturedObjectSyncMessages!![0] - assertEquals("protocolId2:0", populatedObjectSyncMessage.id) // OM2a - id should be protocolId:index - assertEquals(9876543210L, populatedObjectSyncMessage.timestamp) // OM2e - timestamp from protocol message - assertEquals("protocolConnectionId", populatedObjectSyncMessage.connectionId) // OM2c - connectionId from protocol message - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt deleted file mode 100644 index 2a9ac5b13..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt +++ /dev/null @@ -1,944 +0,0 @@ -package io.ably.lib.objects.unit.objects - -import io.ably.lib.objects.* -import io.ably.lib.objects.DefaultRealtimeObjects -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.ObjectsOperationSource -import io.ably.lib.objects.ObjectsState -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.unit.* -import io.ably.lib.objects.unit.getDefaultRealtimeObjectsWithMockedDeps -import io.ably.lib.types.AblyException -import io.ably.lib.types.ErrorInfo -import io.ably.lib.util.Log -import io.mockk.* -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.yield -import org.junit.Test -import kotlin.test.* - -class ObjectsManagerTest { - - // Track instances created in tests to ensure background coroutines are cancelled at teardown - private val testInstances = mutableListOf() - - private fun makeRealtimeObjects(channelName: String = "testChannel"): DefaultRealtimeObjects { - return DefaultRealtimeObjects(channelName, getMockObjectsAdapter()).also { testInstances.add(it) } - } - - @Test - fun `(RTO5) ObjectsManager should handle object sync messages`() { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - assertEquals(ObjectsState.Initialized, defaultRealtimeObjects.state, "Initial state should be INITIALIZED") - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - mockZeroValuedObjects() - - // Populate objectsPool with existing objects - val objectsPool = defaultRealtimeObjects.ObjectsPool - objectsPool.set("map:testObject@1", mockk(relaxed = true)) - objectsPool.set("counter:testObject@4", mockk(relaxed = true)) - - // Incoming object messages - val objectMessage1 = ObjectMessage( - id = "testId1", - objectState = ObjectState( - objectId = "map:testObject@1", // already exists in pool - tombstone = false, - siteTimeserials = mapOf("site1" to "syncSerial1"), - map = ObjectsMap(), - ) - ) - val objectMessage2 = ObjectMessage( - id = "testId2", - objectState = ObjectState( - objectId = "counter:testObject@2", // Does not exist in pool - tombstone = false, - siteTimeserials = mapOf("site1" to "syncSerial1"), - counter = ObjectsCounter(count = 20.0) - ) - ) - val objectMessage3 = ObjectMessage( - id = "testId3", - objectState = ObjectState( - objectId = "map:testObject@3", // Does not exist in pool - tombstone = false, - siteTimeserials = mapOf("site1" to "syncSerial1"), - map = ObjectsMap(), - ) - ) - // Should start and end sync, apply object states, and create new objects for missing ones - objectsManager.handleObjectSyncMessages(listOf(objectMessage1, objectMessage2, objectMessage3), "sync-123:") - - verify(exactly = 1) { - objectsManager.startNewSync("sync-123") - } - verify(exactly = 1) { - objectsManager.endSync() // - } - val newlyCreatedObjects = mutableListOf() - verify(exactly = 2) { - objectsManager["createObjectFromState"](capture(newlyCreatedObjects)) - } - assertEquals("counter:testObject@2", newlyCreatedObjects[0].objectId) - assertEquals("map:testObject@3", newlyCreatedObjects[1].objectId) - - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state, "State should be SYNCED after sync sequence") - // After sync `counter:testObject@4` will be removed from pool - assertNull(objectsPool.get("counter:testObject@4")) - assertEquals(4, objectsPool.size(), "Objects pool should contain 4 objects after sync including root") - assertNotNull(objectsPool.get(ROOT_OBJECT_ID), "Root object should still exist in pool") - val testObject1 = objectsPool.get("map:testObject@1") - assertNotNull(testObject1, "map:testObject@1 should exist in pool after sync") - verify(exactly = 1) { - testObject1.applyObjectSync(any()) - } - val testObject2 = objectsPool.get("counter:testObject@2") - assertNotNull(testObject2, "counter:testObject@2 should exist in pool after sync") - verify(exactly = 1) { - testObject2.applyObjectSync(any()) - } - val testObject3 = objectsPool.get("map:testObject@3") - assertNotNull(testObject3, "map:testObject@3 should exist in pool after sync") - verify(exactly = 1) { - testObject3.applyObjectSync(any()) - } - } - - @Test - fun `(RTO8) ObjectsManager should apply object operation when state is synced`() { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - defaultRealtimeObjects.state = ObjectsState.Synced // Ensure we're in SYNCED state - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - mockZeroValuedObjects() - - // Populate objectsPool with existing objects - val objectsPool = defaultRealtimeObjects.ObjectsPool - objectsPool.set("map:testObject@1", mockk(relaxed = true)) - - // Incoming object messages with operation field instead of objectState - val objectMessage1 = ObjectMessage( - id = "testId1", - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, // Assuming this is the right action for maps - objectId = "map:testObject@1", // already exists in pool - ), - serial = "serial1", - siteCode = "site1" - ) - - val objectMessage2 = ObjectMessage( - id = "testId2", - operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, // Set the counter value - objectId = "counter:testObject@2", // Does not exist in pool - ), - serial = "serial2", - siteCode = "site1" - ) - - val objectMessage3 = ObjectMessage( - id = "testId3", - operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testObject@3", // Does not exist in pool - ), - serial = "serial3", - siteCode = "site1" - ) - - // RTO8b - Apply messages immediately if synced - objectsManager.handleObjectMessages(listOf(objectMessage1, objectMessage2, objectMessage3)) - assertEquals(0, objectsManager.BufferedObjectOperations.size, "No buffer needed in SYNCED state") - - assertEquals(4, objectsPool.size(), "Objects pool should contain 4 objects including root") - assertNotNull(objectsPool.get(ROOT_OBJECT_ID), "Root object should still exist in pool") - - val testObject1 = objectsPool.get("map:testObject@1") - assertNotNull(testObject1, "map:testObject@1 should exist in pool after sync") - verify(exactly = 1) { - testObject1.applyObject(objectMessage1, any()) - } - val testObject2 = objectsPool.get("counter:testObject@2") - assertNotNull(testObject2, "counter:testObject@2 should exist in pool after sync") - verify(exactly = 1) { - testObject2.applyObject(objectMessage2, any()) - } - val testObject3 = objectsPool.get("map:testObject@3") - assertNotNull(testObject3, "map:testObject@3 should exist in pool after sync") - verify(exactly = 1) { - testObject3.applyObject(objectMessage3, any()) - } - } - - @Test - fun `(RTO7) ObjectsManager should buffer operations when not in sync, apply them after synced`() { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - assertEquals(ObjectsState.Initialized, defaultRealtimeObjects.state, "Initial state should be INITIALIZED") - - val objectsManager = defaultRealtimeObjects.ObjectsManager - assertEquals(0, objectsManager.BufferedObjectOperations.size, "RTO7a1 - Initial buffer should be empty") - - val objectsPool = defaultRealtimeObjects.ObjectsPool - assertEquals(1, objectsPool.size(), "RTO7a2 - Initial pool should contain only root object") - - mockZeroValuedObjects() - - // Set state to SYNCING - defaultRealtimeObjects.state = ObjectsState.Syncing - - val objectMessage = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "counter:testObject@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTO7a - Buffer operations during sync - objectsManager.handleObjectMessages(listOf(objectMessage)) - - verify(exactly = 0) { - objectsManager["applyObjectMessages"](any>(), any()) - } - assertEquals(1, objectsManager.BufferedObjectOperations.size) - assertEquals(objectMessage, objectsManager.BufferedObjectOperations[0]) - assertEquals(1, objectsPool.size(), "Pool should still contain only root object during sync") - - // RTO7 - Apply buffered operations after sync - objectsManager.endSync() // End sync without new sync - verify(exactly = 1) { - objectsManager["applyObjectMessages"](any>(), any()) - } - assertEquals(0, objectsManager.BufferedObjectOperations.size) - assertEquals(2, objectsPool.size(), "Pool should contain 2 objects after applying buffered operations") - assertNotNull(objectsPool.get("counter:testObject@1"), "Counter object should be created after sync") - assertTrue(objectsPool.get("counter:testObject@1") is DefaultLiveCounter, "Should create a DefaultLiveCounter object") - } - - @Test - fun `(RTO23 COUNTER_INC) applyAckResult applies COUNTER_INC locally and tracks serial in appliedOnAckSerials`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-ack-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - objectsManager.applyAckResult(listOf(msg)) - - // Verify operation applied locally (RTO23) - assertEquals(5.0, counter.data.get(), "COUNTER_INC should be applied locally on ACK") - // Serial added to appliedOnAckSerials (RTO9a2a4) - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-ack-01"), - "serial should be in appliedOnAckSerials") - // siteTimeserials NOT updated (LOCAL source, RTLC7c) - assertFalse(counter.siteTimeserials.containsKey("site1"), - "siteTimeserials should NOT be updated for LOCAL source") - } - - @Test - fun `(RTO23 MAP_SET) applyAckResult applies MAP_SET locally and tracks serial in appliedOnAckSerials`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val liveMap = DefaultLiveMap.zeroValue("map:testMap@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("map:testMap@1", liveMap) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet(key = "key1", value = ObjectData(string = "value1")) - ), - serial = "ser-map-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - objectsManager.applyAckResult(listOf(msg)) - - // Verify entry was set (LOCAL source) - assertEquals("value1", liveMap.data["key1"]?.data?.string, - "MAP_SET should be applied locally on ACK") - // Entry timeserial should be updated (within LiveMapManager, regardless of source) - assertEquals("ser-map-01", liveMap.data["key1"]?.timeserial, - "entry timeserial should be set by MAP_SET") - // Serial added to appliedOnAckSerials - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-map-01"), - "serial should be in appliedOnAckSerials") - // Object-level siteTimeserials NOT updated (LOCAL source, RTLM15c) - assertFalse(liveMap.siteTimeserials.containsKey("site1"), - "siteTimeserials should NOT be updated for LOCAL source") - } - - @Test - fun `(RTO9a3) echo CHANNEL message is deduplicated - serial removed, data NOT re-applied`() { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(10.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - // Simulate: serial already applied locally on ACK - defaultRealtimeObjects.appliedOnAckSerials.add("ser-echo-01") - - val echoMsg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-echo-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - objectsManager.handleObjectMessages(listOf(echoMsg)) - - // Data NOT double-applied (RTO9a3) - assertEquals(10.0, counter.data.get(), "data should NOT be re-applied on echo dedup") - // Serial removed from appliedOnAckSerials (RTO9a3) - assertFalse(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-echo-01"), - "serial should be removed from appliedOnAckSerials after dedup") - // siteTimeserials NOT updated - discarded without further action (RTO9a3) - assertNull(counter.siteTimeserials["site1"], - "siteTimeserials should NOT be updated by echo dedup (RTO9a3: discard without further action)") - } - - @Test - fun `(RTO9) non-echo CHANNEL message is applied normally when serial not in appliedOnAckSerials`() { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(10.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 3.0) - ), - serial = "ser-channel-01", - siteCode = "site1" - ) - - // serial NOT in appliedOnAckSerials — this is a regular (non-echo) CHANNEL message - assertFalse(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-channel-01")) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - objectsManager.handleObjectMessages(listOf(msg)) - - // Should be applied normally (CHANNEL source) - assertEquals(13.0, counter.data.get(), "counter should be incremented by CHANNEL message") - // siteTimeserials IS updated for CHANNEL source (RTLC7c) - assertEquals("ser-channel-01", counter.siteTimeserials["site1"], - "siteTimeserials should be updated for CHANNEL source") - } - - @Test - fun `(RTO22) applyAckResult waits for SYNCED state and applies with LOCAL source after endSync`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Syncing - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-ack-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - // Launch applyAckResult in background — will suspend while SYNCING - val ackJob = launch { - objectsManager.applyAckResult(listOf(msg)) - } - - // Allow the coroutine to start and reach deferred.await() - yield() - - // During SYNCING — waiter is pending, message NOT yet applied - assertNotNull(objectsManager.SyncCompletionWaiter, "sync completion should be pending during SYNCING") - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.isEmpty(), - "appliedOnAckSerials should be empty while waiting") - assertEquals(0.0, counter.data.get(), "data should not be applied while SYNCING") - - // End sync — completes waiters (schedules resume), then transitions to SYNCED - objectsManager.endSync() - ackJob.join() - - // After endSync — message applied with LOCAL source, serial tracked - assertEquals(5.0, counter.data.get(), "counter should be incremented after endSync") - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-ack-01"), - "serial should be tracked in appliedOnAckSerials after LOCAL apply") - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state) - } - - @Test - fun `(RTO5c6) endSync applies buffered CHANNEL messages then unblocks pending ACK waiters`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(10.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - val incMsg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-01", - siteCode = "site1" - ) - - // Start a new sync (state → SYNCING) - objectsManager.startNewSync(null) - assertEquals(ObjectsState.Syncing, defaultRealtimeObjects.state) - - // Suspend the ACK waiter (SYNCING) - val ackJob = launch { - objectsManager.applyAckResult(listOf(incMsg)) - } - yield() - assertNotNull(objectsManager.SyncCompletionWaiter) - - // Buffer the echo OBJECT message (also buffered since SYNCING) - objectsManager.handleObjectMessages(listOf(incMsg)) - assertEquals(1, objectsManager.BufferedObjectOperations.size) - - // End sync — applies CHANNEL buffered messages first, clears appliedOnAckSerials, then unblocks waiters - objectsManager.endSync() - ackJob.join() - - // After endSync: - // 1. CHANNEL echo applied: counter = 10 + 5 = 15; siteTimeserials["site1"] = "ser-01" - // 2. appliedOnAckSerials cleared (was empty since no LOCAL applied during sync) - // 3. Waiter resumes → LOCAL apply → canApplyOperation rejects (serial not newer) → applied=false - assertEquals(15.0, counter.data.get(), "counter should be incremented exactly once") - assertEquals("ser-01", counter.siteTimeserials["site1"], - "siteTimeserials should be updated by CHANNEL echo") - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.isEmpty(), - "appliedOnAckSerials should be empty (LOCAL apply was rejected by canApplyOperation)") - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state) - } - - @Test - fun `(RTO5c9) endSync applies buffered CHANNEL messages then clears appliedOnAckSerials`() { - val defaultRealtimeObjects = makeRealtimeObjects() - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(10.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - // Start a sync - objectsManager.startNewSync(null) - assertEquals(ObjectsState.Syncing, defaultRealtimeObjects.state) - - // Buffer a CHANNEL message during sync - val channelMsg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 3.0) - ), - serial = "ser-channel-01", - siteCode = "site1" - ) - objectsManager.handleObjectMessages(listOf(channelMsg)) - assertEquals(1, objectsManager.BufferedObjectOperations.size) - - // Simulate a serial that was somehow added during sync - defaultRealtimeObjects.appliedOnAckSerials.add("ser-during-sync") - - // End sync — CHANNEL messages applied first, then appliedOnAckSerials cleared (RTO5c9) - objectsManager.endSync() - - // CHANNEL message was applied (counter incremented) - assertEquals(13.0, counter.data.get(), - "buffered CHANNEL message should be applied by endSync") - // appliedOnAckSerials cleared at sync end (RTO5c9) - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.isEmpty(), - "appliedOnAckSerials should be cleared at sync end (RTO5c9)") - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state) - } - - @Test - fun `(RTO20e1) failBufferedAcks fails pending deferreds with error code 92008`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Syncing - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - val error = AblyException.fromErrorInfo( - ErrorInfo("channel failed while waiting for sync", 400, 92008) - ) - - var caughtException: Exception? = null - val ackJob = launch { - try { - objectsManager.applyAckResult(listOf(msg)) - } catch (e: Exception) { - caughtException = e - } - } - - // Allow the coroutine to start and suspend on deferred.await() - yield() - - // Fail the buffered ACK (RTO20e1) - objectsManager.failBufferedAcks(error) - - ackJob.join() - - assertNotNull(caughtException, "buffered ACK should fail with an exception") - val ablyEx = caughtException as? AblyException - assertNotNull(ablyEx, "exception should be an AblyException") - assertEquals(92008, ablyEx.errorInfo.code, - "error code should be 92008 (PublishAndApplyFailedDueToChannelState)") - assertEquals(400, ablyEx.errorInfo.statusCode, "status code should be 400") - } - - @Test - fun `Echo arrives before ACK - operation applied exactly once via canApplyOperation`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(10.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - // Step 1: echo arrives first as CHANNEL message — applied normally - objectsManager.handleObjectMessages(listOf(msg)) - assertEquals(15.0, counter.data.get(), "echo should be applied as CHANNEL message") - assertEquals("ser-01", counter.siteTimeserials["site1"], - "siteTimeserials should be updated by CHANNEL echo") - - // Step 2: ACK fires — applyAckResult with same serial (state is SYNCED, no suspend) - objectsManager.applyAckResult(listOf(msg)) - - // canApplyOperation rejects (serial "ser-01" is not newer than siteTimeserials["site1"] = "ser-01") - assertEquals(15.0, counter.data.get(), "counter should NOT be incremented again by late ACK apply") - // applied=false → serial NOT added to appliedOnAckSerials - assertFalse(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-01"), - "serial should NOT be in appliedOnAckSerials when LOCAL apply was rejected") - } - - @Test - fun `publishAndApply logs error and returns without apply when siteCode is null`() = runTest { - val adapter = getMockObjectsAdapter() - // Create a ConnectionManager mock with all fields needed for publish() to succeed - val cm = mockk(relaxed = true) - cm.maxMessageSize = 65536 // direct field assignment bypasses mock interception issues - every { cm.isActive } returns true - every { cm.send(any(), any(), any()) } answers { - @Suppress("UNCHECKED_CAST") - val callback = thirdArg>() - callback.onSuccess(io.ably.lib.types.PublishResult(null)) // null serials → RTO20c2 path - } - every { adapter.connectionManager } returns cm - // siteCode is null (relaxed mock default) — triggers RTO20c1 graceful degradation path - - val defaultRealtimeObjects = DefaultRealtimeObjects("testChannel", adapter).also { testInstances.add(it) } - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ) - ) - - // Should not throw even when siteCode is null (RTO20c1 graceful degradation) - defaultRealtimeObjects.publishAndApply(arrayOf(msg)) - - assertEquals(0.0, counter.data.get(), "no local apply should happen when siteCode is null") - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.isEmpty(), - "appliedOnAckSerials should be empty when siteCode is null") - } - - @Test - fun `(issue 7b) publishAndApply logs error and returns without apply when serials length mismatches`() = runTest { - val adapter = getMockObjectsAdapter() - // Create a ConnectionManager mock that returns a PublishResult with wrong-length serials - val cm = mockk(relaxed = true) - cm.maxMessageSize = 65536 // direct field assignment bypasses mock interception issues - every { cm.isActive } returns true - cm.siteCode = "site1" // direct field assignment (siteCode is a Java public field) - every { cm.send(any(), any(), any()) } answers { - @Suppress("UNCHECKED_CAST") - val callback = thirdArg>() - callback.onSuccess(io.ably.lib.types.PublishResult(arrayOfNulls(0))) // wrong length (0 instead of 1) - } - every { adapter.connectionManager } returns cm - - val defaultRealtimeObjects = DefaultRealtimeObjects("testChannel", adapter).also { testInstances.add(it) } - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ) - ) - - // Should not throw even when serials length mismatches (RTO20c2 graceful degradation) - defaultRealtimeObjects.publishAndApply(arrayOf(msg)) - - assertEquals(0.0, counter.data.get(), "no local apply should happen when serials length mismatches") - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.isEmpty(), - "appliedOnAckSerials should be empty when serials length mismatches") - } - - @Test - fun `(RTO5f2a2) partial sync map entries are merged across two messages with the same objectId`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - val msg1 = ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = mapOf("key1" to ObjectsMapEntry(data = ObjectData(string = "value1")))) - ) - ) - val msg2 = ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial2"), - map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = mapOf("key2" to ObjectsMapEntry(data = ObjectData(string = "value2")))) - ) - ) - - objectsManager.handleObjectSyncMessages(listOf(msg1, msg2), "sync-1:") - - val liveMap = defaultRealtimeObjects.objectsPool.get("map:test@1") as DefaultLiveMap - assertNotNull(liveMap.data["key1"], "key1 should be present after merge") - assertNotNull(liveMap.data["key2"], "key2 should be present after merge") - assertEquals("value1", liveMap.data["key1"]?.data?.string) - assertEquals("value2", liveMap.data["key2"]?.data?.string) - } - - @Test - fun `(RTO5f2a2) partial sync map entries merged across separate protocol messages`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - val objectId = "map:test@1" - val siteTimeserials = mapOf("site1" to "serial1") - - // Protocol message 1: first partial (has cursor → not ending) - objectsManager.handleObjectSyncMessages( - listOf( - ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = objectId, - tombstone = false, - siteTimeserials = siteTimeserials, - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf("key1" to ObjectsMapEntry(data = ObjectData(string = "value1"))) - ) - ) - ) - ), - "sync-1:cursor1" - ) - - // Protocol message 2: second partial for same objectId (has cursor → not ending) - objectsManager.handleObjectSyncMessages( - listOf( - ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = objectId, - tombstone = false, - siteTimeserials = siteTimeserials, - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf("key2" to ObjectsMapEntry(data = ObjectData(string = "value2"))) - ) - ) - ) - ), - "sync-1:cursor2" - ) - - // Protocol message 3: third partial for same objectId (empty cursor → ends sync) - objectsManager.handleObjectSyncMessages( - listOf( - ObjectMessage( - id = "msg3", - objectState = ObjectState( - objectId = objectId, - tombstone = false, - siteTimeserials = siteTimeserials, - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf("key3" to ObjectsMapEntry(data = ObjectData(string = "value3"))) - ) - ) - ) - ), - "sync-1:" // empty cursor → sync ends, applySync() runs - ) - - // Verify all 3 keys from 3 separate protocol messages are merged into the live map - val liveMap = defaultRealtimeObjects.objectsPool.get(objectId) as DefaultLiveMap - assertNotNull(liveMap.data["key1"], "key1 from first protocol message should be present") - assertNotNull(liveMap.data["key2"], "key2 from second protocol message should be present") - assertNotNull(liveMap.data["key3"], "key3 from third protocol message should be present") - assertEquals("value1", liveMap.data["key1"]?.data?.string) - assertEquals("value2", liveMap.data["key2"]?.data?.string) - assertEquals("value3", liveMap.data["key3"]?.data?.string) - } - - @Test - fun `(RTO5c1b1c) unsupported object type during sync is skipped without breaking other objects`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - mockkStatic(Log::class) - every { Log.w(any(), any()) } returns 0 - - // msg1: valid map object - val msg1 = ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf("key1" to ObjectsMapEntry(data = ObjectData(string = "value1"))) - ) - ) - ) - // msg2: unsupported type (neither counter nor map) - val msg2 = ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = "unknown:test@2", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - ) - ) - // msg3: valid counter object - val msg3 = ObjectMessage( - id = "msg3", - objectState = ObjectState( - objectId = "counter:test@3", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - counter = ObjectsCounter(count = 42.0) - ) - ) - - // Send all three in one sync — msg2 should be skipped, msg1 and msg3 should be applied - objectsManager.handleObjectSyncMessages(listOf(msg1, msg2, msg3), "sync-1:") - - val liveMap = defaultRealtimeObjects.objectsPool.get("map:test@1") as DefaultLiveMap - assertNotNull(liveMap.data["key1"], "valid map object should be created despite unsupported object in same sync") - - val counter = defaultRealtimeObjects.objectsPool.get("counter:test@3") as DefaultLiveCounter - assertEquals(42.0, counter.data.get(), "valid counter should be created despite unsupported object in same sync") - - // Unsupported object should NOT be in the pool - assertNull(defaultRealtimeObjects.objectsPool.get("unknown:test@2"), "unsupported object type should not be in pool") - } - - @Test - fun `(RTO5f2a1) tombstone on second partial message replaces pool entry entirely`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - val msg1 = ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = mapOf("key1" to ObjectsMapEntry(data = ObjectData(string = "value1")))) - ) - ) - val msg2 = ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = true, - siteTimeserials = mapOf("site1" to "serial2"), - map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = emptyMap()) - ) - ) - - objectsManager.handleObjectSyncMessages(listOf(msg1, msg2), "sync-1:") - - val liveMap = defaultRealtimeObjects.objectsPool.get("map:test@1") as DefaultLiveMap - // After tombstone replaces the entry, the map should have no key1 - assertNull(liveMap.data["key1"], "key1 should not be present after tombstone replaced the pool entry") - } - - @Test - fun `(RTO5f2b) partial sync counter message logs error and is skipped`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - mockkStatic(Log::class) - every { Log.e(any(), any()) } returns 0 - - val msg1 = ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = "counter:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - counter = ObjectsCounter(count = 10.0) - ) - ) - val msg2 = ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = "counter:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial2"), - counter = ObjectsCounter(count = 5.0) - ) - ) - - objectsManager.handleObjectSyncMessages(listOf(msg1, msg2), "sync-1:") - - // Pool should contain only msg1 (msg2 skipped) - val counter = defaultRealtimeObjects.objectsPool.get("counter:test@1") as DefaultLiveCounter - assertEquals(10.0, counter.data.get(), "counter value should be from msg1 only (msg2 skipped)") - verify { Log.e(any(), match { it.contains("partial sync message for a counter") }) } - } - - @Test - fun `(RTO5f2c) partial sync message with unsupported type logs warning and is skipped`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - mockkStatic(Log::class) - every { Log.w(any(), any()) } returns 0 - - val msg1 = ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = mapOf("key1" to ObjectsMapEntry(data = ObjectData(string = "value1")))) - ) - ) - // msg2 has neither map nor counter — hits the else branch (RTO5f2c) - val msg2 = ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial2"), - ) - ) - - objectsManager.handleObjectSyncMessages(listOf(msg1, msg2), "sync-1:") - - // Pool entry should still be msg1 (msg2 was skipped) - val liveMap = defaultRealtimeObjects.objectsPool.get("map:test@1") as DefaultLiveMap - assertNotNull(liveMap.data["key1"], "key1 should still be present (msg2 skipped)") - verify { Log.w(any(), match { it.contains("unsupported object type") }) } - } - - private fun mockZeroValuedObjects() { - mockkObject(DefaultLiveMap.Companion) - every { - DefaultLiveMap.zeroValue(any(), any()) - } answers { - mockk(relaxed = true) - } - mockkObject(DefaultLiveCounter.Companion) - every { - DefaultLiveCounter.zeroValue(any(), any()) - } answers { - mockk(relaxed = true) - } - } - - @AfterTest - fun tearDown() { - val cleanupError = AblyException.fromErrorInfo(ErrorInfo("test cleanup", 500)) - testInstances.forEach { it.dispose(cleanupError) } - testInstances.clear() - unmockkAll() // Clean up all mockk objects after each test - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt deleted file mode 100644 index aff4f9d1a..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -package io.ably.lib.objects.unit.objects - -import io.ably.lib.objects.DefaultRealtimeObjects -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ROOT_OBJECT_ID -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.type.livemap.LiveMapEntry -import io.ably.lib.objects.unit.* -import io.mockk.mockk -import io.mockk.spyk -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class ObjectsPoolTest { - - @Test - fun `(RTO3, RTO3a, RTO3b) An internal ObjectsPool should be used to maintain the list of objects present on a channel`() { - val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", getMockObjectsAdapter()) - val objectsPool = defaultRealtimeObjects.objectsPool - assertNotNull(objectsPool) - - // RTO3b - It must always contain a LiveMap object with id root - val rootLiveMap = objectsPool.get(ROOT_OBJECT_ID) - assertNotNull(rootLiveMap) - assertTrue(rootLiveMap is DefaultLiveMap) - assertTrue(rootLiveMap.data.isEmpty()) - assertEquals(ROOT_OBJECT_ID, rootLiveMap.objectId) - assertEquals(1, objectsPool.size(), "RTO3 - Should only contain the root object initially") - - // RTO3a - ObjectsPool is a Dict, a map of RealtimeObjects keyed by objectId string - val testLiveMap = DefaultLiveMap.zeroValue("map:testObject@1", mockk(relaxed = true)) - objectsPool.set("map:testObject@1", testLiveMap) - val testLiveCounter = DefaultLiveCounter.zeroValue("counter:testObject@1", mockk(relaxed = true)) - objectsPool.set("counter:testObject@1", testLiveCounter) - // Assert that the objects are stored in the pool - assertEquals(testLiveMap, objectsPool.get("map:testObject@1")) - assertEquals(testLiveCounter, objectsPool.get("counter:testObject@1")) - assertEquals(3, objectsPool.size(), "RTO3 - Should have 3 objects in pool (root + testLiveMap + testLiveCounter)") - } - - @Test - fun `(RTO6) ObjectsPool should create zero-value objects if not exists`() { - val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", getMockObjectsAdapter()) - val objectsPool = spyk(defaultRealtimeObjects.objectsPool) - assertEquals(1, objectsPool.size(), "RTO3 - Should only contain the root object initially") - - // Test creating zero-value map - // RTO6b1, RTO6b2 - Type is parsed from the objectId format (map:hash@timestamp) - val mapId = "map:xyz789@67890" - val map = objectsPool.createZeroValueObjectIfNotExists(mapId) - assertNotNull(map, "Should create a map object") - assertTrue(map is DefaultLiveMap, "RTO6b2 - Should create a LiveMap for map type") - assertEquals(mapId, map.objectId) - assertTrue(map.data.isEmpty(), "RTO6b2 - Should create an empty map") - assertEquals(2, objectsPool.size(), "RTO6 - root + map should be in pool after creation") - - // Test creating zero-value counter - // RTO6b1, RTO6b3 - Type is parsed from the objectId format (counter:hash@timestamp) - val counterId = "counter:abc123@12345" - val counter = objectsPool.createZeroValueObjectIfNotExists(counterId) - assertNotNull(counter, "Should create a counter object") - assertTrue(counter is DefaultLiveCounter, "RTO6b3 - Should create a LiveCounter for counter type") - assertEquals(counterId, counter.objectId) - assertEquals(0.0, counter.data.get(), "RTO6b3 - Should create a zero-value counter") - assertEquals(3, objectsPool.size(), "RTO6 - root + map + counter should be in pool after creation") - - // RTO6a - If object exists in pool, do not create a new one - val existingMap = objectsPool.createZeroValueObjectIfNotExists(mapId) - assertEquals(map, existingMap, "RTO6a - Should return existing object, not create a new one") - val existingCounter = objectsPool.createZeroValueObjectIfNotExists(counterId) - assertEquals(counter, existingCounter, "RTO6a - Should return existing object, not create a new one") - assertEquals(3, objectsPool.size(), "RTO6 - Should still have 3 objects in pool after re-creation attempt") - } - - @Test - fun `(RTO4b1, RTO4b2) ObjectsPool should reset to initial pool retaining original root map`() { - val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", getMockObjectsAdapter()) - val objectsPool = defaultRealtimeObjects.objectsPool - assertEquals(1, objectsPool.size()) - val rootMap = objectsPool.get(ROOT_OBJECT_ID) as DefaultLiveMap - // add some data to the root map - rootMap.data["initialKey1"] = LiveMapEntry(data = ObjectData("testValue1")) - rootMap.data["initialKey2"] = LiveMapEntry(data = ObjectData("testValue2")) - assertEquals(2, rootMap.data.size, "RTO3 - Root map should have initial data") - - // Add some objects - objectsPool.set("counter:testObject@1", DefaultLiveCounter.zeroValue("counter:testObject@1", mockk(relaxed = true))) - assertEquals(2, objectsPool.size()) // root + testObject - objectsPool.set("counter:testObject@2", DefaultLiveCounter.zeroValue("counter:testObject@2", mockk(relaxed = true))) - assertEquals(3, objectsPool.size()) // root + testObject + anotherObject - objectsPool.set("map:testObject@1", DefaultLiveMap.zeroValue("map:testObject@1", mockk(relaxed = true))) - assertEquals(4, objectsPool.size()) // root + testObject + anotherObject + testMap - - // Reset to initial pool - objectsPool.resetToInitialPool(true) - - // RTO4b1 - Should only contain root object - assertEquals(1, objectsPool.size()) - assertEquals(rootMap, objectsPool.get(ROOT_OBJECT_ID)) - // RTO4b2 - RootMap should be empty after reset - assertTrue(rootMap.data.isEmpty(), "RTO3 - Root map should be empty after reset") - } - - @Test - fun `(RTO5c2, RTO5c2a) ObjectsPool should delete extra object IDs`() { - val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", getMockObjectsAdapter()) - val objectsPool = defaultRealtimeObjects.objectsPool - - // Add some objects - objectsPool.set("counter:testObject@1", DefaultLiveCounter.zeroValue("counter:testObject@1", mockk(relaxed = true))) - objectsPool.set("counter:testObject@2", DefaultLiveCounter.zeroValue("counter:testObject@2", mockk(relaxed = true))) - objectsPool.set("counter:testObject@3", DefaultLiveCounter.zeroValue("counter:testObject@3", mockk(relaxed = true))) - assertEquals(4, objectsPool.size()) // root + 3 objects - - // Delete extra object IDs (keep only object1 and object2) - val receivedObjectIds = mutableSetOf("counter:testObject@1", "counter:testObject@2") - objectsPool.deleteExtraObjectIds(receivedObjectIds) - - // Should only contain root, object1, and object2 - assertEquals(3, objectsPool.size()) - // RTO5c2a - Should keep the root object - assertNotNull(objectsPool.get(ROOT_OBJECT_ID)) - // RTO5c2 - Should delete object3 and keep object1 and object2 - assertNotNull(objectsPool.get("counter:testObject@1")) - assertNotNull(objectsPool.get("counter:testObject@2")) - assertNull(objectsPool.get("counter:testObject@3")) // Should be deleted - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseRealtimeObjectTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseRealtimeObjectTest.kt deleted file mode 100644 index 9868bf680..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseRealtimeObjectTest.kt +++ /dev/null @@ -1,172 +0,0 @@ -package io.ably.lib.objects.unit.type - -import io.ably.lib.objects.* -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.unit.getDefaultRealtimeObjectsWithMockedDeps -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlin.test.assertFailsWith - -class BaseRealtimeObjectTest { - - private val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - @Test - fun `(RTLO1, RTLO2) BaseRealtimeObject should be abstract base class for LiveMap and LiveCounter`() { - // RTLO2 - Check that BaseRealtimeObject is abstract - val isAbstract = java.lang.reflect.Modifier.isAbstract(BaseRealtimeObject::class.java.modifiers) - assertTrue(isAbstract, "BaseRealtimeObject should be an abstract class") - - // RTLO1 - Check that BaseRealtimeObject is the parent class of DefaultLiveMap and DefaultLiveCounter - assertTrue(BaseRealtimeObject::class.java.isAssignableFrom(DefaultLiveMap::class.java), - "DefaultLiveMap should extend BaseRealtimeObject") - assertTrue(BaseRealtimeObject::class.java.isAssignableFrom(DefaultLiveCounter::class.java), - "DefaultLiveCounter should extend BaseRealtimeObject") - } - - @Test - fun `(RTLO3) BaseRealtimeObject should have required properties`() { - val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) - val liveCounter: BaseRealtimeObject = DefaultLiveCounter.zeroValue("counter:testObject@1", defaultRealtimeObjects) - // RTLO3a - check that objectId is set correctly - assertEquals("map:testObject@1", liveMap.objectId) - assertEquals("counter:testObject@1", liveCounter.objectId) - - // RTLO3b, RTLO3b1 - check that siteTimeserials is initialized as an empty map - assertEquals(emptyMap(), liveMap.siteTimeserials) - assertEquals(emptyMap(), liveCounter.siteTimeserials) - - // RTLO3c - Create operation merged flag - assertFalse(liveMap.createOperationIsMerged, "Create operation should not be merged by default") - assertFalse(liveCounter.createOperationIsMerged, "Create operation should not be merged by default") - } - - @Test - fun `(RTLO4a1, RTLO4a2) canApplyOperation should accept ObjectMessage params and return boolean`() { - // RTLO4a1a - Assert parameter types and return type based on method signature using reflection - val method = BaseRealtimeObject::class.java.findMethod("canApplyOperation") - - // RTLO4a1a - Verify parameter types - val parameters = method.parameters - assertEquals(2, parameters.size, "canApplyOperation should have exactly 2 parameters") - - // First parameter should be String? (siteCode) - assertEquals(String::class.java, parameters[0].type, "First parameter should be of type String?") - assertTrue(parameters[0].isVarArgs.not(), "First parameter should not be varargs") - - // Second parameter should be String? (timeSerial) - assertEquals(String::class.java, parameters[1].type, "Second parameter should be of type String?") - assertTrue(parameters[1].isVarArgs.not(), "Second parameter should not be varargs") - - // RTLO4a2 - Verify return type - assertEquals(Boolean::class.java, method.returnType, "canApplyOperation should return Boolean") - } - - @Test - fun `(RTLO4a3) canApplyOperation should throw error for null or empty incoming siteSerial`() { - val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) - - // Test null serial - assertFailsWith("Should throw error for null serial") { - liveMap.canApplyOperation("site1", null) - } - - // Test empty serial - assertFailsWith("Should throw error for empty serial") { - liveMap.canApplyOperation("site1", "") - } - - // Test null siteCode - assertFailsWith("Should throw error for null site code") { - liveMap.canApplyOperation(null, "serial1") - } - - // Test empty siteCode - assertFailsWith("Should throw error for empty site code") { - liveMap.canApplyOperation("", "serial1") - } - } - - @Test - fun `(RTLO4a4, RTLO4a5) canApplyOperation should return true when existing siteSerial is null or empty`() { - val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) - assertTrue(liveMap.siteTimeserials.isEmpty(), "Initial siteTimeserials should be empty") - - // RTLO4a4 - Get siteSerial from siteTimeserials map - // RTLO4a5 - Return true when siteSerial is null (no entry in map) - assertTrue(liveMap.canApplyOperation("site1", "serial1"), - "Should return true when no siteSerial exists for the site") - - // RTLO4a5 - Return true when siteSerial is empty string - liveMap.siteTimeserials["site1"] = "" - assertTrue(liveMap.canApplyOperation("site1", "serial1"), - "Should return true when siteSerial is empty string") - } - - @Test - fun `(RTLO4a6) canApplyOperation should return true when message siteSerial is greater than existing siteSerial`() { - val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) - - // Set existing siteSerial - liveMap.siteTimeserials["site1"] = "serial1" - - // RTLO4a6 - Return true when message serial is greater (lexicographically) - assertTrue(liveMap.canApplyOperation("site1", "serial2"), - "Should return true when message serial 'serial2' > siteSerial 'serial1'") - - assertTrue(liveMap.canApplyOperation("site1", "serial10"), - "Should return true when message serial 'serial10' > siteSerial 'serial1'") - - assertTrue(liveMap.canApplyOperation("site1", "serialA"), - "Should return true when message serial 'serialA' > siteSerial 'serial1'") - } - - @Test - fun `(RTLO4a6) canApplyOperation should return false when message siteSerial is less than or equal to siteSerial`() { - val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) - - // Set existing siteSerial - liveMap.siteTimeserials["site1"] = "serial2" - - // RTLO4a6 - Return false when message serial is less than siteSerial - assertFalse(liveMap.canApplyOperation("site1", "serial1"), - "Should return false when message serial 'serial1' < siteSerial 'serial2'") - - // RTLO4a6 - Return false when message serial equals siteSerial - assertFalse(liveMap.canApplyOperation("site1", "serial2"), - "Should return false when message serial equals siteSerial") - - // RTLO4a6 - Return false when message serial is less (lexicographically) - assertTrue(liveMap.canApplyOperation("site1", "serialA"), - "Should return true when message serial 'serialA' > siteSerial 'serial2'") - } - - @Test - fun `(RTLO4a) canApplyOperation should work with different site codes`() { - val liveMap: BaseRealtimeObject = DefaultLiveCounter.zeroValue("counter:testObject@1", defaultRealtimeObjects) - - // Set serials for different sites - liveMap.siteTimeserials["site1"] = "serial1" - liveMap.siteTimeserials["site2"] = "serial5" - - // Test site1 - assertTrue(liveMap.canApplyOperation("site1", "serial2"), - "Should return true for site1 when serial2 > serial1") - assertFalse(liveMap.canApplyOperation("site1", "serial1"), - "Should return false for site1 when serial1 = serial1") - - // Test site2 - assertTrue(liveMap.canApplyOperation("site2", "serial6"), - "Should return true for site2 when serial6 > serial5") - assertFalse(liveMap.canApplyOperation("site2", "serial4"), - "Should return false for site2 when serial4 < serial5") - - // Test new site (should return true) - assertTrue(liveMap.canApplyOperation("site3", "serial1"), - "Should return true for new site with any serial") - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt deleted file mode 100644 index 3e82cebc9..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt +++ /dev/null @@ -1,262 +0,0 @@ -package io.ably.lib.objects.unit.type.livecounter - -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectsOperationSource -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.unit.getDefaultLiveCounterWithMockedDeps -import io.ably.lib.types.AblyException -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class DefaultLiveCounterTest { - @Test - fun `(RTLC6, RTLC6a) DefaultLiveCounter should override serials with state serials from sync`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - // Set initial data - liveCounter.siteTimeserials["site1"] = "serial1" - liveCounter.siteTimeserials["site2"] = "serial2" - - val objectState = ObjectState( - objectId = "counter:testCounter@1", - siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), - tombstone = false, - ) - - val objectMessage = ObjectMessage( - id = "testId", - objectState = objectState, - serial = "serial1", - siteCode = "site1" - ) - - liveCounter.applyObjectSync(objectMessage) - assertEquals(mapOf("site3" to "serial3", "site4" to "serial4"), liveCounter.siteTimeserials) // RTLC6a - } - - @Test - fun `(RTLC7, RTLC7a) DefaultLiveCounter should check objectId before applying operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "counter:testCounter@2", // Different objectId - counterCreate = CounterCreate(count = 20.0) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7a - Should throw error when objectId doesn't match - val exception = assertFailsWith { - liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - } - val errorInfo = exception.errorInfo - assertNotNull(errorInfo) - - // Assert on error codes - assertEquals(92000, exception.errorInfo?.code) // InvalidObject error code - assertEquals(500, exception.errorInfo?.statusCode) // InternalServerError status code - } - - @Test - fun `(RTLC7, RTLC7b) DefaultLiveCounter should validate site serial before applying operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - // Set existing site serial that is newer than the incoming message - liveCounter.siteTimeserials["site1"] = "serial2" // Newer than "serial1" - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "counter:testCounter@1", // Matching objectId - counterCreate = CounterCreate(count = 20.0) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial1", // Older serial - siteCode = "site1" - ) - - // RTLC7b - Should skip operation when serial is not newer - liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - // Verify that the site serial was not updated (operation was skipped) - assertEquals("serial2", liveCounter.siteTimeserials["site1"]) - } - - @Test - fun `(RTLC7, RTLC7c) DefaultLiveCounter should update site serial if valid`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - // Set existing site serial that is older than the incoming message - liveCounter.siteTimeserials["site1"] = "serial1" // Older than "serial2" - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "counter:testCounter@1", // Matching objectId - counterCreate = CounterCreate(count = 20.0) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial2", // Newer serial - siteCode = "site1" - ) - - // RTLC7c - Should update site serial when operation is valid - liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - // Verify that the site serial was updated - assertEquals("serial2", liveCounter.siteTimeserials["site1"]) - } - - @Test - fun `(RTLC7c LOCAL) applyObject with LOCAL source updates data but does NOT update siteTimeserials`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - assertTrue(liveCounter.siteTimeserials.isEmpty(), "siteTimeserials should start empty") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:testCounter@1", - counterInc = io.ably.lib.objects.CounterInc(number = 5.0) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7c - LOCAL source: data IS updated, siteTimeserials is NOT updated - val result = liveCounter.applyObject(message, ObjectsOperationSource.LOCAL) - - assertTrue(result, "applyObject should return true for successful COUNTER_INC") - assertEquals(5.0, liveCounter.data.get(), "data should be updated for LOCAL source") - assertFalse(liveCounter.siteTimeserials.containsKey("site1"), - "siteTimeserials should NOT be updated for LOCAL source") - } - - @Test - fun `(RTLC7b return) applyObject returns false when incoming serial is not newer than existing`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - liveCounter.siteTimeserials["site1"] = "serial5" // Newer than incoming "serial1" - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:testCounter@1", - counterInc = io.ably.lib.objects.CounterInc(number = 5.0) - ), - serial = "serial1", // Older than "serial5" - siteCode = "site1" - ) - - // RTLC7b - Should return false when canApplyOperation fails - val result = liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertFalse(result, "applyObject should return false when serial is not newer") - assertEquals(0.0, liveCounter.data.get(), "data should not be changed") - assertEquals("serial5", liveCounter.siteTimeserials["site1"], "siteTimeserials should not change") - } - - @Test - fun `(RTLC7e return) applyObject returns false when object is tombstoned`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - liveCounter.tombstone(null) // Tombstone the object - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:testCounter@1", - counterInc = io.ably.lib.objects.CounterInc(number = 5.0) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7e - Should return false when object is tombstoned - val result = liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertFalse(result, "applyObject should return false when object is tombstoned") - } - - @Test - fun `(RTLC7d2b) applyObject returns true for successful COUNTER_INC`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:testCounter@1", - counterInc = io.ably.lib.objects.CounterInc(number = 5.0) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7d2b - Should return true for successful COUNTER_INC - val result = liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for successful COUNTER_INC") - assertEquals(5.0, liveCounter.data.get()) - } - - @Test - fun `(RTLC7d1b) applyObject returns true for successful COUNTER_CREATE`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "counter:testCounter@1", - counterCreate = CounterCreate(count = 20.0) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7d1b - Should return true for successful COUNTER_CREATE - val result = liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for successful COUNTER_CREATE") - } - - @Test - fun `(RTLC7d4b) applyObject returns true for OBJECT_DELETE (tombstone)`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.ObjectDelete, - objectId = "counter:testCounter@1", - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7d4b - Should return true for OBJECT_DELETE (tombstone applied) - val result = liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for OBJECT_DELETE") - assertTrue(liveCounter.isTombstoned, "object should be tombstoned") - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt deleted file mode 100644 index e7dda488f..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt +++ /dev/null @@ -1,356 +0,0 @@ -package io.ably.lib.objects.unit.type.livecounter - -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.unit.LiveCounterManager -import io.ably.lib.objects.unit.TombstonedAt -import io.ably.lib.objects.unit.getDefaultLiveCounterWithMockedDeps -import io.ably.lib.types.AblyException -import org.junit.Test -import kotlin.test.* - -class DefaultLiveCounterManagerTest { - - @Test - fun `(RTLC6, RTLC6b, RTLC6c) DefaultLiveCounter should override counter data with state from sync`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val objectState = ObjectState( - objectId = "testCounterId", - counter = ObjectsCounter(count = 25.0), - siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), - tombstone = false, - ) - - val update = liveCounterManager.applyState(objectState, null) - - assertFalse(liveCounter.createOperationIsMerged) // RTLC6b - assertEquals(25.0, liveCounter.data.get()) // RTLC6c - assertEquals(15.0, update.update.amount) // Difference between old and new data - } - - - @Test - fun `(RTLC6, RTLC6d) DefaultLiveCounter should merge create operation in state from sync`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(5.0) - - val createOp = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = CounterCreate(count = 10.0) - ) - - val objectState = ObjectState( - objectId = "testCounterId", - counter = ObjectsCounter(count = 15.0), - createOp = createOp, - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - // RTLC6d - Merge initial data from create operation - val update = liveCounterManager.applyState(objectState, null) - - assertEquals(25.0, liveCounter.data.get()) // 15 from state + 10 from create op - assertEquals(20.0, update.update.amount) // Total change - } - - - @Test - fun `(RTLC7d1b) LiveCounterManager applyOperation returns true for COUNTER_CREATE`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = CounterCreate(count = 10.0) - ) - - // RTLC7d1b - Should return true for successful COUNTER_CREATE - val result = liveCounterManager.applyOperation(operation, null) - assertTrue(result, "applyOperation should return true for COUNTER_CREATE") - } - - @Test - fun `(RTLC7d2b) LiveCounterManager applyOperation returns true for COUNTER_INC`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "testCounterId", - counterInc = CounterInc(number = 5.0) - ) - - // RTLC7d2b - Should return true for successful COUNTER_INC - val result = liveCounterManager.applyOperation(operation, null) - assertTrue(result, "applyOperation should return true for COUNTER_INC") - } - - @Test - fun `(RTLC7d4b) LiveCounterManager applyOperation returns true for OBJECT_DELETE`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.ObjectDelete, - objectId = "testCounterId", - ) - - // RTLC7d4b - Should return true for OBJECT_DELETE (tombstone) - val result = liveCounterManager.applyOperation(operation, null) - assertTrue(result, "applyOperation should return true for OBJECT_DELETE") - assertTrue(liveCounter.isTombstoned, "counter should be tombstoned after ObjectDelete") - } - - @Test - fun `(RTLC7, RTLC7d3) LiveCounterManager should return false for unsupported action`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, // Unsupported action for counter - objectId = "testCounterId", - mapCreate = MapCreate(semantics = ObjectsMapSemantics.LWW, entries = emptyMap()) - ) - - // RTLC7d3 - Should return false for unsupported action (no longer throws) - val result = liveCounterManager.applyOperation(operation, null) - assertFalse(result, "Should return false for unsupported action") - } - - @Test - fun `(RTLC7, RTLC7d1, RTLC8) LiveCounterManager should apply counter create operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = CounterCreate(count = 20.0) - ) - - // RTLC7d1 - Apply counter create operation - liveCounterManager.applyOperation(operation, null) - - assertEquals(20.0, liveCounter.data.get()) // Should be set to counter count - assertTrue(liveCounter.createOperationIsMerged) // Should be marked as merged - } - - @Test - fun `(RTLC8, RTLC8b) LiveCounterManager should skip counter create operation if already merged`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - liveCounter.data.set(4.0) // Start with 4 - - // Set create operation as already merged - liveCounter.createOperationIsMerged = true - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = CounterCreate(count = 20.0) - ) - - // RTLC8b - Should skip if already merged - liveCounterManager.applyOperation(operation, null) - - assertEquals(4.0, liveCounter.data.get()) // Should not change (still 0) - assertTrue(liveCounter.createOperationIsMerged) // Should remain merged - } - - @Test - fun `(RTLC8, RTLC8c) LiveCounterManager should apply counter create operation if not merged`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - // Set initial data - liveCounter.data.set(10.0) // Start with 10 - - // Set create operation as not merged - liveCounter.createOperationIsMerged = false - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = CounterCreate(count = 20.0) - ) - - // RTLC8c - Should apply if not merged - liveCounterManager.applyOperation(operation, null) - assertTrue(liveCounter.createOperationIsMerged) // Should be marked as merged - - assertEquals(30.0, liveCounter.data.get()) // Should be set to counter count - assertTrue(liveCounter.createOperationIsMerged) // RTLC16b - Should be marked as merged - } - - @Test - fun `(RTLC8, RTLC16) LiveCounterManager should handle null count in create operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = null // No count specified - ) - - // RTLC16a - Should default to 0 - liveCounterManager.applyOperation(operation, null) - - assertEquals(10.0, liveCounter.data.get()) // No change (null defaults to 0) - assertTrue(liveCounter.createOperationIsMerged) // RTLC16b - } - - @Test - fun `(RTLC7, RTLC7d2, RTLC9) LiveCounterManager should apply counter increment operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "testCounterId", - counterInc = CounterInc(number = 5.0) - ) - - // RTLC7d2 - Apply counter increment operation - liveCounterManager.applyOperation(operation, null) - - assertEquals(15.0, liveCounter.data.get()) // RTLC9f - 10 + 5 - } - - @Test - fun `(RTLC7, RTLC7d2) LiveCounterManager should throw error for missing payload for counter increment operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "testCounterId", - counterInc = null // Missing payload - ) - - // RTLC7d2 - Should throw error for missing payload - val exception = assertFailsWith { - liveCounterManager.applyOperation(operation, null) - } - - val errorInfo = exception.errorInfo - assertNotNull(errorInfo) - assertEquals(92000, errorInfo.code) // InvalidObject error code - assertEquals(500, errorInfo.statusCode) // InternalServerError status code - } - - - @Test - fun `(RTLC9, RTLC9f) LiveCounterManager should apply counter increment operation correctly`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val counterInc = CounterInc(number = 7.0) - - // RTLC9f - Apply counter increment - liveCounterManager.applyOperation(ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "testCounterId", - counterInc = counterInc - ), null) - - assertEquals(17.0, liveCounter.data.get()) // 10 + 7 - } - - @Test - fun `(RTLC7, RTLC7d2) LiveCounterManager should throw error when counterInc payload missing`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - // RTLC7d2 - Apply counter increment with no payload - throws error - val exception = assertFailsWith { - liveCounterManager.applyOperation(ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "testCounterId", - counterInc = null - ), null) - } - assertNotNull(exception.errorInfo) - assertEquals(92000, exception.errorInfo.code) - } - - @Test - fun `(RTLC6, OM2j) DefaultLiveCounter should handle tombstone with serialTimestamp in state`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val expectedTimestamp = 1234567890L - val objectState = ObjectState( - objectId = "testCounterId", - counter = null, // Null counter for tombstone - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = true, // Object is tombstoned - ) - - val update = liveCounterManager.applyState(objectState, expectedTimestamp) - - assertTrue(liveCounter.isTombstoned) // Should be tombstoned - assertEquals(expectedTimestamp, liveCounter.TombstonedAt) // Should use provided timestamp - assertEquals(0.0, liveCounter.data.get()) // Should be reset after tombstone - - // Assert on update field - should show the change - assertEquals(-10.0, update.update.amount) // Difference from 10.0 to 0.0 - } - - @Test - fun `(RTLC6, OM2j) DefaultLiveCounter should handle tombstone without serialTimestamp in state`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val objectState = ObjectState( - objectId = "testCounterId", - counter = null, // Null counter for tombstone - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = true, // Object is tombstoned - ) - - val beforeOperation = System.currentTimeMillis() - val update = liveCounterManager.applyState(objectState, null) - val afterOperation = System.currentTimeMillis() - - assertTrue(liveCounter.isTombstoned) // Should be tombstoned - assertNotNull(liveCounter.TombstonedAt) // Should have timestamp - assertTrue(liveCounter.TombstonedAt!! >= beforeOperation) // Should be after operation start - assertTrue(liveCounter.TombstonedAt!! <= afterOperation) // Should be before operation end - assertEquals(0.0, liveCounter.data.get()) // Should be reset after tombstone - - // Assert on update field - should show the change - assertEquals(-10.0, update.update.amount) // Difference from 10.0 to 0.0 - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt deleted file mode 100644 index 7ddd43937..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt +++ /dev/null @@ -1,276 +0,0 @@ -package io.ably.lib.objects.unit.type.livemap - -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectsMap -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.ObjectsOperationSource -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.unit.* -import io.ably.lib.types.AblyException -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class DefaultLiveMapTest { - @Test - fun `(RTLM6, RTLM6a) DefaultLiveMap should override serials with state serials from sync`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - // Set initial data - liveMap.siteTimeserials["site1"] = "serial1" - liveMap.siteTimeserials["site2"] = "serial2" - - val objectState = ObjectState( - objectId = "map:testMap@1", - siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), - tombstone = false, - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - ) - ) - - val objectMessage = ObjectMessage( - id = "testId", - objectState = objectState, - serial = "serial1", - siteCode = "site1" - ) - - liveMap.applyObjectSync(objectMessage) - assertEquals(mapOf("site3" to "serial3", "site4" to "serial4"), liveMap.siteTimeserials) // RTLM6a - } - - @Test - fun `(RTLM15, RTLM15a) DefaultLiveMap should check objectId before applying operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@2", // Different objectId - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap() - ) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15a - Should throw error when objectId doesn't match - val exception = assertFailsWith { - liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - } - val errorInfo = exception.errorInfo - assertNotNull(errorInfo) - - // Assert on error codes - assertEquals(92000, exception.errorInfo?.code) // InvalidObject error code - assertEquals(500, exception.errorInfo?.statusCode) // InternalServerError status code - } - - @Test - fun `(RTLM15, RTLM15b) DefaultLiveMap should validate site serial before applying operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - // Set existing site serial that is newer than the incoming message - liveMap.siteTimeserials["site1"] = "serial2" // Newer than "serial1" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", // Matching objectId - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap() - ) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial1", // Older serial - siteCode = "site1" - ) - - // RTLM15b - Should skip operation when serial is not newer - liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - // Verify that the site serial was not updated (operation was skipped) - assertEquals("serial2", liveMap.siteTimeserials["site1"]) - } - - @Test - fun `(RTLM15, RTLM15c) DefaultLiveMap should update site serial if valid`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - // Set existing site serial that is older than the incoming message - liveMap.siteTimeserials["site1"] = "serial1" // Older than "serial2" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", // Matching objectId - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap() - ) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial2", // Newer serial - siteCode = "site1" - ) - - // RTLM15c - Should update site serial when operation is valid - liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - // Verify that the site serial was updated - assertEquals("serial2", liveMap.siteTimeserials["site1"]) - } - - @Test - fun `(RTLM15c LOCAL) applyObject with LOCAL source updates data but does NOT update siteTimeserials`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - assertTrue(liveMap.siteTimeserials.isEmpty(), "siteTimeserials should start empty") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = io.ably.lib.objects.MapSet(key = "key1", value = io.ably.lib.objects.ObjectData(string = "value1")) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15c - LOCAL source: data IS updated (entry set), siteTimeserials is NOT updated - val result = liveMap.applyObject(message, ObjectsOperationSource.LOCAL) - - assertTrue(result, "applyObject should return true for successful MAP_SET") - assertEquals("value1", liveMap.data["key1"]?.data?.string, "map entry should be updated for LOCAL source") - assertFalse(liveMap.siteTimeserials.containsKey("site1"), - "siteTimeserials should NOT be updated for LOCAL source") - } - - @Test - fun `(RTLM15b return) applyObject returns false when incoming serial is not newer than existing`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - liveMap.siteTimeserials["site1"] = "serial5" // Newer than incoming "serial1" - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = io.ably.lib.objects.MapSet(key = "key1", value = io.ably.lib.objects.ObjectData(string = "value1")) - ), - serial = "serial1", // Older than "serial5" - siteCode = "site1" - ) - - // RTLM15b - Should return false when canApplyOperation fails - val result = liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertFalse(result, "applyObject should return false when serial is not newer") - assertEquals("serial5", liveMap.siteTimeserials["site1"], "siteTimeserials should not change") - } - - @Test - fun `(RTLM15e return) applyObject returns false when object is tombstoned`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - liveMap.tombstone(null) // Tombstone the object - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = io.ably.lib.objects.MapSet(key = "key1", value = io.ably.lib.objects.ObjectData(string = "value1")) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15e - Should return false when object is tombstoned - val result = liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertFalse(result, "applyObject should return false when object is tombstoned") - } - - @Test - fun `(RTLM15d2b) applyObject returns true for successful MAP_SET`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = io.ably.lib.objects.MapSet(key = "key1", value = io.ably.lib.objects.ObjectData(string = "value1")) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15d2b - Should return true for successful MAP_SET - val result = liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for successful MAP_SET") - assertEquals("value1", liveMap.data["key1"]?.data?.string) - } - - @Test - fun `(RTLM15d3b) applyObject returns true for successful MAP_REMOVE`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = io.ably.lib.objects.MapRemove(key = "key1") - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15d3b - Should return true for successful MAP_REMOVE - val result = liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for successful MAP_REMOVE") - } - - @Test - fun `(RTLM15d5b) applyObject returns true for OBJECT_DELETE (tombstone)`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.ObjectDelete, - objectId = "map:testMap@1", - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15d5b - Should return true for OBJECT_DELETE (tombstone applied) - val result = liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for OBJECT_DELETE") - assertTrue(liveMap.isTombstoned, "object should be tombstoned") - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt deleted file mode 100644 index adaf4eb81..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt +++ /dev/null @@ -1,1388 +0,0 @@ -package io.ably.lib.objects.unit.type.livemap - -import io.ably.lib.objects.* -import io.ably.lib.objects.MapClear -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.type.livemap.LiveMapEntry -import io.ably.lib.objects.type.livemap.LiveMapManager -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.objects.unit.LiveMapManager -import io.ably.lib.objects.unit.TombstonedAt -import io.ably.lib.objects.unit.getDefaultLiveMapWithMockedDeps -import io.ably.lib.types.AblyException -import io.mockk.mockk -import org.junit.Test -import org.junit.Assert.* -import kotlin.test.* - -class LiveMapManagerTest { - - private val livemapManager = LiveMapManager(mockk(relaxed = true)) - - @Test - fun `(RTLM6, RTLM6b, RTLM6c) DefaultLiveMap should override map data with state from sync`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "newValue1"), - timeserial = "serial1" - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "value2"), - timeserial = "serial2" - ) - ) - ), - siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), - tombstone = false, - ) - - val update = liveMapManager.applyState(objectState, null) - - assertFalse(liveMap.createOperationIsMerged) // RTLM6b - assertEquals(2, liveMap.data.size) // RTLM6c - assertEquals("newValue1", liveMap.data["key1"]?.data?.string) // RTLM6c - assertEquals("value2", liveMap.data["key2"]?.data?.string) // RTLM6c - - // Assert on update field - should show changes from old to new state - val expectedUpdate = mapOf( - "key1" to LiveMapUpdate.Change.UPDATED, // key1 was updated from "oldValue" to "newValue1" - "key2" to LiveMapUpdate.Change.UPDATED // key2 was added - ) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, RTLM6c) DefaultLiveMap should handle empty map entries in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap() // Empty map entries - ), - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - val update = liveMapManager.applyState(objectState, null) - - assertFalse(liveMap.createOperationIsMerged) // RTLM6b - assertEquals(0, liveMap.data.size) // RTLM6c - should be empty map - - // Assert on update field - should show that key1 was removed - val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, RTLM6c) DefaultLiveMap should handle null map in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = null, // Null map - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - val update = liveMapManager.applyState(objectState, null) - - assertFalse(liveMap.createOperationIsMerged) // RTLM6b - assertEquals(0, liveMap.data.size) // RTLM6c - should be empty map when map is null - - // Assert on update field - should show that key1 was removed - val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, RTLM6d) DefaultLiveMap should merge initial data from create operation from state in sync`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "existingValue") - ) - - val createOp = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "createValue"), - timeserial = "serial1" - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "newValue"), - timeserial = "serial2" - ) - ) - ) - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "stateValue"), - timeserial = "serial3" - ) - ) - ), - createOp = createOp, - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - // RTLM6d - Merge initial data from create operation - val update = liveMapManager.applyState(objectState, null) - - assertEquals(2, liveMap.data.size) // Should have both state and create op entries - assertEquals("stateValue", liveMap.data["key1"]?.data?.string) // State value takes precedence - assertEquals("newValue", liveMap.data["key2"]?.data?.string) // Create op value - - // Assert on update field - should show changes from create operation - val expectedUpdate = mapOf( - "key1" to LiveMapUpdate.Change.UPDATED, // key1 was updated from "existingValue" to "stateValue" - "key2" to LiveMapUpdate.Change.UPDATED // key2 was added from create operation - ) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, RTLM6c, OME2d) DefaultLiveMap should handle tombstoned entries with serialTimestamp in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val expectedTimestamp = 1234567890L - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "newValue"), - timeserial = "serial1", - tombstone = true, - serialTimestamp = expectedTimestamp - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "value2"), - timeserial = "serial2" - ) - ) - ), - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - val update = liveMapManager.applyState(objectState, null) - - assertFalse(liveMap.createOperationIsMerged) // RTLM6b - assertEquals(2, liveMap.data.size) // RTLM6c - assertTrue(liveMap.data["key1"]?.isTombstoned == true) // Should be tombstoned - assertEquals(expectedTimestamp, liveMap.data["key1"]?.tombstonedAt) // Should use provided serialTimestamp - assertEquals("value2", liveMap.data["key2"]?.data?.string) // RTLM6c - - // Assert on update field - should show that key1 was removed (tombstoned) - val expectedUpdate = mapOf( - "key1" to LiveMapUpdate.Change.REMOVED, // key1 was tombstoned - "key2" to LiveMapUpdate.Change.UPDATED // key2 was added - ) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, RTLM6c, OME2d) DefaultLiveMap should handle tombstoned entries without serialTimestamp in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "newValue"), - timeserial = "serial1", - tombstone = true, - serialTimestamp = null // No timestamp provided - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "value2"), - timeserial = "serial2" - ) - ) - ), - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - val beforeOperation = System.currentTimeMillis() - val update = liveMapManager.applyState(objectState, null) - val afterOperation = System.currentTimeMillis() - - assertFalse(liveMap.createOperationIsMerged) // RTLM6b - assertEquals(2, liveMap.data.size) // RTLM6c - assertTrue(liveMap.data["key1"]?.isTombstoned == true) // Should be tombstoned - assertNotNull(liveMap.data["key1"]?.tombstonedAt) // Should have timestamp - assertTrue(liveMap.data["key1"]?.tombstonedAt!! >= beforeOperation) // Should be after operation start - assertTrue(liveMap.data["key1"]?.tombstonedAt!! <= afterOperation) // Should be before operation end - assertEquals("value2", liveMap.data["key2"]?.data?.string) // RTLM6c - - // Assert on update field - should show that key1 was removed (tombstoned) - val expectedUpdate = mapOf( - "key1" to LiveMapUpdate.Change.REMOVED, // key1 was tombstoned - "key2" to LiveMapUpdate.Change.UPDATED // key2 was added - ) - assertEquals(expectedUpdate, update.update) - } - - - @Test - fun `(RTLM15, RTLM15d1, RTLM16) LiveMapManager should apply map create operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "value1"), - timeserial = "serial1" - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "value2"), - timeserial = "serial2" - ) - ) - ) - ) - - // RTLM15d1 - Apply map create operation - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(2, liveMap.data.size) // Should have both entries - assertEquals("value1", liveMap.data["key1"]?.data?.string) // Should have value1 - assertEquals("value2", liveMap.data["key2"]?.data?.string) // Should have value2 - assertTrue(liveMap.createOperationIsMerged) // Should be marked as merged - } - - @Test - fun `(RTLM16, RTLM16d, RTLM23, OME2d) LiveMapManager should merge initial data from create operation with tombstoned entries`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "existingValue") - ) - - val expectedTimestamp = 1234567890L - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "createValue"), - timeserial = "serial2", - tombstone = true, - serialTimestamp = expectedTimestamp - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "newValue"), - timeserial = "serial3" - ), - "key3" to ObjectsMapEntry( - data = null, - timeserial = "serial4", - tombstone = true - ) - ) - ) - ) - - // RTLM16d - Merge initial data from create operation - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(3, liveMap.data.size) // Should have all entries - assertTrue(liveMap.data["key1"]?.isTombstoned == true) // RTLM23a2 - Should be tombstoned - assertEquals(expectedTimestamp, liveMap.data["key1"]?.tombstonedAt) // Should use provided serialTimestamp - assertEquals("newValue", liveMap.data["key2"]?.data?.string) // RTLM23a1 - Should be added - assertTrue(liveMap.data["key3"]?.isTombstoned == true) // RTLM23a2 - Should be tombstoned - assertTrue(liveMap.createOperationIsMerged) // RTLM23b - Should be marked as merged - } - - @Test - fun `(RTLM15, RTLM15d2, RTLM7) LiveMapManager should apply map set operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "oldValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM15d2 - Apply map set operation - liveMapManager.applyOperation(operation, "serial2", null) - - assertEquals("newValue", liveMap.data["key1"]?.data?.string) // RTLM7a2a - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // RTLM7a2b - assertFalse(liveMap.data["key1"]?.isTombstoned == true) // RTLM7a2c - } - - @Test - fun `(RTLM15, RTLM15d3, RTLM8) LiveMapManager should apply map remove operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "value1") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "key1") - ) - - val expectedTimestamp = 1234567890L - // RTLM15d3 - Apply map remove operation with provided timestamp - liveMapManager.applyOperation(operation, "serial2", expectedTimestamp) - - assertNull(liveMap.data["key1"]?.data) // RTLM8a2a - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // RTLM8a2b - assertTrue(liveMap.data["key1"]?.isTombstoned == true) // RTLM8a2c - assertEquals(expectedTimestamp, liveMap.data["key1"]?.tombstonedAt) // RTLM8c3 - Should use provided timestamp - } - - @Test - fun `(RTLM8, RTLM8c3, OME2d) LiveMapManager should use current time when no timestamp provided for map remove operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "value1") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "key1") - ) - - val beforeOperation = System.currentTimeMillis() - // RTLM8c3 - Apply map remove operation without timestamp (should use current time) - liveMapManager.applyOperation(operation, "serial2", null) - val afterOperation = System.currentTimeMillis() - - assertNull(liveMap.data["key1"]?.data) // RTLM8a2a - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // RTLM8a2b - assertTrue(liveMap.data["key1"]?.isTombstoned == true) // RTLM8a2c - assertNotNull(liveMap.data["key1"]?.tombstonedAt) // Should have timestamp - assertTrue(liveMap.data["key1"]?.tombstonedAt!! >= beforeOperation) // Should be after operation start - assertTrue(liveMap.data["key1"]?.tombstonedAt!! <= afterOperation) // Should be before operation end - } - - @Test - fun `(RTLM15d1b) LiveMapManager applyOperation returns true for MAP_CREATE`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate(semantics = ObjectsMapSemantics.LWW, entries = emptyMap()) - ) - - // RTLM15d1b - Should return true for successful MAP_CREATE - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertTrue(result, "applyOperation should return true for MAP_CREATE") - } - - @Test - fun `(RTLM15d2b) LiveMapManager applyOperation returns true for MAP_SET`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet(key = "key1", value = ObjectData(string = "value1")) - ) - - // RTLM15d2b - Should return true for successful MAP_SET - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertTrue(result, "applyOperation should return true for MAP_SET") - } - - @Test - fun `(RTLM15d3b) LiveMapManager applyOperation returns true for MAP_REMOVE`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "key1") - ) - - // RTLM15d3b - Should return true for successful MAP_REMOVE - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertTrue(result, "applyOperation should return true for MAP_REMOVE") - } - - @Test - fun `(RTLM15d5b) LiveMapManager applyOperation returns true for OBJECT_DELETE`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.ObjectDelete, - objectId = "map:testMap@1", - ) - - // RTLM15d5b - Should return true for OBJECT_DELETE (tombstone) - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertTrue(result, "applyOperation should return true for OBJECT_DELETE") - assertTrue(liveMap.isTombstoned, "map should be tombstoned after ObjectDelete") - } - - @Test - fun `(RTLM15, RTLM15d4) LiveMapManager should return false for unsupported action`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, // Unsupported action for map - objectId = "map:testMap@1", - counterCreate = io.ably.lib.objects.CounterCreate(count = 20.0) - ) - - // RTLM15d4 - Should return false for unsupported action (no longer throws) - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertFalse(result, "Should return false for unsupported action") - } - - @Test - fun `(RTLM16, RTLM16b) LiveMapManager should skip map create operation if already merged`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set create operation as already merged - liveMap.createOperationIsMerged = true - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "value1"), - timeserial = "serial1" - ) - ) - ) - ) - - // RTLM16b - Should skip if already merged - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(0, liveMap.data.size) // Should not change (still empty) - assertTrue(liveMap.createOperationIsMerged) // Should remain merged - } - - - - @Test - fun `(RTLM16, RTLM16d, RTLM23) LiveMapManager should merge initial data from create operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "createValue"), - timeserial = "serial2" - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "newValue"), - timeserial = "serial3" - ), - "key3" to ObjectsMapEntry( - data = null, - timeserial = "serial4", - tombstone = true - ) - ) - ) - ) - - // RTLM16d - Merge initial data from create operation - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(3, liveMap.data.size) // Should have all entries - assertEquals("createValue", liveMap.data["key1"]?.data?.string) // RTLM23a1 - Should be updated - assertEquals("newValue", liveMap.data["key2"]?.data?.string) // RTLM23a1 - Should be added - assertTrue(liveMap.data["key3"]?.isTombstoned == true) // RTLM23a2 - Should be tombstoned - assertTrue(liveMap.createOperationIsMerged) // RTLM23b - Should be marked as merged - } - - @Test - fun `(RTLM7, RTLM7b) LiveMapManager should create new entry for map set operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "newKey", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM7b - Create new entry - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(1, liveMap.data.size) // Should have one entry - assertEquals("newValue", liveMap.data["newKey"]?.data?.string) // RTLM7b1 - assertEquals("serial1", liveMap.data["newKey"]?.timeserial) // Should have serial - assertFalse(liveMap.data["newKey"]?.isTombstoned == true) // RTLM7b2 - } - - @Test - fun `(RTLM7, RTLM7a) LiveMapManager should skip map set operation with lower serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with higher serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial2", // Higher than "serial1" - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM7a - Should skip operation with lower serial - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals("existingValue", liveMap.data["key1"]?.data?.string) // Should not change - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should keep original serial - } - - @Test - fun `(RTLM8, RTLM8b) LiveMapManager should create tombstoned entry for map remove operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "nonExistingKey") - ) - - // RTLM8b - Create tombstoned entry for non-existing key - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(1, liveMap.data.size) // Should have one entry - assertNull(liveMap.data["nonExistingKey"]?.data) // RTLM8b1 - assertEquals("serial1", liveMap.data["nonExistingKey"]?.timeserial) // Should have serial - assertTrue(liveMap.data["nonExistingKey"]?.isTombstoned == true) // RTLM8b2 - } - - @Test - fun `(RTLM8, RTLM8a) LiveMapManager should skip map remove operation with lower serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with higher serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial2", // Higher than "serial1" - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "key1") - ) - - // RTLM8a - Should skip operation with lower serial - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals("existingValue", liveMap.data["key1"]?.data?.string) // Should not change - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should keep original serial - assertFalse(liveMap.data["key1"]?.isTombstoned == true) // Should not be tombstoned - } - - @Test - fun `(RTLM9, RTLM9b) LiveMapManager should handle null serials correctly`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with null serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = null, - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM9b - Both null serials should be treated as equal - liveMapManager.applyOperation(operation, null, null) - - assertEquals("existingValue", liveMap.data["key1"]?.data?.string) // Should not change - } - - @Test - fun `(RTLM9, RTLM9d) LiveMapManager should apply operation with serial when entry has null serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with null serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = null, - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM9d - Operation serial is greater than missing entry serial - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals("newValue", liveMap.data["key1"]?.data?.string) // Should be updated - assertEquals("serial1", liveMap.data["key1"]?.timeserial) // Should have new serial - } - - @Test - fun `(RTLM9, RTLM9c) LiveMapManager should skip operation with null serial when entry has serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM9c - Missing operation serial is lower than existing entry serial - liveMapManager.applyOperation(operation, null, null) - - assertEquals("existingValue", liveMap.data["key1"]?.data?.string) // Should not change - assertEquals("serial1", liveMap.data["key1"]?.timeserial) // Should keep original serial - } - - @Test - fun `(RTLM9, RTLM9e) LiveMapManager should apply operation with higher serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with lower serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM9e - Higher serial should be applied - liveMapManager.applyOperation(operation, "serial2", null) - - assertEquals("newValue", liveMap.data["key1"]?.data?.string) // Should be updated - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should have new serial - } - - @Test - fun `(RTLM9, RTLM9e) LiveMapManager should skip operation with lower serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with higher serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial2", - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM9e - Lower serial should be skipped - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals("existingValue", liveMap.data["key1"]?.data?.string) // Should not change - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should keep original serial - } - - @Test - fun `(RTLM16, RTLM16c) DefaultLiveMap should throw error for mismatched semantics`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.Unknown, // This should match, but we'll test error case - entries = emptyMap() - ) - ) - - val exception = assertFailsWith { - liveMapManager.applyOperation(operation, "serial1", null) - } - - val errorInfo = exception.errorInfo - kotlin.test.assertNotNull(errorInfo, "Error info should not be null") // RTLM16c - - // Assert on error codes - kotlin.test.assertEquals(92000, exception.errorInfo?.code) // InvalidObject error code - kotlin.test.assertEquals(500, exception.errorInfo?.statusCode) // InternalServerError status code - } - - @Test - fun shouldCalculateMapDifferenceCorrectly() { - // Test case 1: No changes - val prevData1 = mapOf() - val newData1 = mapOf() - val result1 = livemapManager.calculateUpdateFromDataDiff(prevData1, newData1) - assertEquals(emptyMap(), result1.update, "Should return empty map for no changes") - - // Test case 2: Entry added - val prevData2 = mapOf() - val newData2 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ) - ) - val result2 = livemapManager.calculateUpdateFromDataDiff(prevData2, newData2) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result2.update, "Should detect added entry") - - // Test case 3: Entry removed - val prevData3 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ) - ) - val newData3 = mapOf() - val result3 = livemapManager.calculateUpdateFromDataDiff(prevData3, newData3) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.REMOVED), result3.update, "Should detect removed entry") - - // Test case 4: Entry updated - val prevData4 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ) - ) - val newData4 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "2", - data = ObjectData(string = "value2") - ) - ) - val result4 = livemapManager.calculateUpdateFromDataDiff(prevData4, newData4) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result4.update, "Should detect updated entry") - - // Test case 5: Entry tombstoned - val prevData5 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ) - ) - val newData5 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = true, - timeserial = "2", - data = null - ) - ) - val result5 = livemapManager.calculateUpdateFromDataDiff(prevData5, newData5) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.REMOVED), result5.update, "Should detect tombstoned entry") - - // Test case 6: Entry untombstoned - val prevData6 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = true, - timeserial = "1", - data = null - ) - ) - val newData6 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "2", - data = ObjectData(string = "value1") - ) - ) - val result6 = livemapManager.calculateUpdateFromDataDiff(prevData6, newData6) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result6.update, "Should detect untombstoned entry") - - // Test case 7: Both entries tombstoned (noop) - val prevData7 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = true, - timeserial = "1", - data = null - ) - ) - val newData7 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = true, - timeserial = "2", - data = ObjectData(string = "value1") - ) - ) - val result7 = livemapManager.calculateUpdateFromDataDiff(prevData7, newData7) - assertEquals(emptyMap(), result7.update, "Should not detect change for both tombstoned entries") - - // Test case 8: New tombstoned entry (noop) - val prevData8 = mapOf() - val newData8 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = true, - timeserial = "1", - data = null - ) - ) - val result8 = livemapManager.calculateUpdateFromDataDiff(prevData8, newData8) - assertEquals(emptyMap(), result8.update, "Should not detect change for new tombstoned entry") - - // Test case 9: Multiple changes - val prevData9 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ), - "key2" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value2") - ) - ) - val newData9 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "2", - data = ObjectData(string = "value1_updated") - ), - "key3" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value3") - ) - ) - val result9 = livemapManager.calculateUpdateFromDataDiff(prevData9, newData9) - val expected9 = mapOf( - "key1" to LiveMapUpdate.Change.UPDATED, - "key2" to LiveMapUpdate.Change.REMOVED, - "key3" to LiveMapUpdate.Change.UPDATED - ) - assertEquals(expected9, result9.update, "Should detect multiple changes correctly") - - // Test case 10: ObjectId references - val prevData10 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(objectId = "obj1") - ) - ) - val newData10 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(objectId = "obj2") - ) - ) - val result10 = livemapManager.calculateUpdateFromDataDiff(prevData10, newData10) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result10.update, "Should detect objectId change") - - // Test case 11: Same data, no change - val prevData11 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ) - ) - val newData11 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "2", - data = ObjectData(string = "value1") - ) - ) - val result11 = livemapManager.calculateUpdateFromDataDiff(prevData11, newData11) - assertEquals(emptyMap(), result11.update, "Should not detect change for same data") - } - - @Test - fun `(RTLM6, OM2j) DefaultLiveMap should handle tombstone with serialTimestamp in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val expectedTimestamp = 1234567890L - val objectState = ObjectState( - objectId = "map:testMap@1", - map = null, // Null map for tombstone - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = true, // Object is tombstoned - ) - - val update = liveMapManager.applyState(objectState, expectedTimestamp) - - assertTrue(liveMap.isTombstoned) // Should be tombstoned - assertEquals(expectedTimestamp, liveMap.TombstonedAt) // Should use provided timestamp - assertEquals(0, liveMap.data.size) // Should be empty after tombstone - - // Assert on update field - should show that key1 was removed - val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, OM2j) DefaultLiveMap should handle tombstone without serialTimestamp in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = null, // Null map for tombstone - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = true, // Object is tombstoned - ) - - val beforeOperation = System.currentTimeMillis() - val update = liveMapManager.applyState(objectState, null) - val afterOperation = System.currentTimeMillis() - - assertTrue(liveMap.isTombstoned) // Should be tombstoned - assertNotNull(liveMap.TombstonedAt) // Should have timestamp - assertTrue(liveMap.TombstonedAt!! >= beforeOperation) // Should be after operation start - assertTrue(liveMap.TombstonedAt!! <= afterOperation) // Should be before operation end - assertEquals(0, liveMap.data.size) // Should be empty after tombstone - - // Assert on update field - should show that key1 was removed - val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM24) applyMapClear removes entries older than clear serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "value1") - ) - liveMap.data["key2"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial3", - data = ObjectData(string = "value2") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapClear, - objectId = "map:testMap@1", - mapClear = MapClear - ) - - // Apply MAP_CLEAR with serial "serial2" — between serial1 and serial3 - liveMapManager.applyOperation(operation, "serial2", null) - - assertNull(liveMap.data["key1"], "Entry at serial1 should be removed") - assertNotNull(liveMap.data["key2"], "Entry at serial3 should be kept") - assertEquals("serial2", liveMap.clearTimeserial) - } - - @Test - fun `(RTLM24c) applyMapClear skips when existing clearTimeserial is newer`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "value1") - ) - liveMap.clearTimeserial = "serial3" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapClear, - objectId = "map:testMap@1", - mapClear = MapClear - ) - - liveMapManager.applyOperation(operation, "serial2", null) - - // clearTimeserial should remain unchanged and data should be untouched - assertEquals("serial3", liveMap.clearTimeserial) - assertNotNull(liveMap.data["key1"], "Entry should not be removed") - } - - @Test - fun `(RTLM25) clearTimeserial is set after MAP_CLEAR`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - assertNull(liveMap.clearTimeserial) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapClear, - objectId = "map:testMap@1", - mapClear = MapClear - ) - - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals("serial1", liveMap.clearTimeserial) - } - - @Test - fun `(RTLM7h) applyMapSet skips when op serial is less than or equal to clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.clearTimeserial = "serial2" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet(key = "key1", value = ObjectData(string = "value1")) - ) - - liveMapManager.applyOperation(operation, "serial1", null) - - assertNull(liveMap.data["key1"], "Entry should NOT be added when op serial <= clearTimeserial") - } - - @Test - fun `(RTLM7h) applyMapSet applies when op serial is greater than clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.clearTimeserial = "serial1" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet(key = "key1", value = ObjectData(string = "value1")) - ) - - liveMapManager.applyOperation(operation, "serial2", null) - - assertNotNull(liveMap.data["key1"], "Entry should be added when op serial > clearTimeserial") - assertEquals("value1", liveMap.data["key1"]?.data?.string) - } - - @Test - fun `(RTLM8g) applyMapRemove skips when op serial is less than or equal to clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial3", - data = ObjectData(string = "value1") - ) - liveMap.clearTimeserial = "serial2" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "key1") - ) - - liveMapManager.applyOperation(operation, "serial1", null) - - assertFalse(liveMap.data["key1"]?.isTombstoned == true, "Entry should NOT be tombstoned when op serial <= clearTimeserial") - } - - @Test - fun `(RTLM6i) applyState sets clearTimeserial from objectState`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap(), - clearTimeserial = "serial1" - ), - siteTimeserials = emptyMap(), - tombstone = false, - ) - - liveMapManager.applyState(objectState, null) - - assertEquals("serial1", liveMap.clearTimeserial) - } - - @Test - fun `(RTLM6i) applyState resets clearTimeserial to null when objectState has no clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.clearTimeserial = "serial1" - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap(), - clearTimeserial = null - ), - siteTimeserials = emptyMap(), - tombstone = false, - ) - - liveMapManager.applyState(objectState, null) - - assertNull(liveMap.clearTimeserial) - } - - @Test - fun `(RTLM6i, RTLM6d, RTLM7h) applyState filters createOp entries older than or equal to clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // createOp has three entries: - // key-null-serial — no timeserial (treated as pre-clear by RTLM7h) - // key-old-serial — serial1, strictly older than the clear serial (serial2) - // key-new-serial — serial3, strictly newer than the clear serial (serial2) - val createOp = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key-null-serial" to ObjectsMapEntry( - data = ObjectData(string = "nullSerialValue"), - timeserial = null - ), - "key-old-serial" to ObjectsMapEntry( - data = ObjectData(string = "oldSerialValue"), - timeserial = "serial1" - ), - "key-new-serial" to ObjectsMapEntry( - data = ObjectData(string = "newSerialValue"), - timeserial = "serial3" - ) - ) - ) - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap(), - clearTimeserial = "serial2" // RTLM6i: set before createOp entries are merged - ), - createOp = createOp, - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - liveMapManager.applyState(objectState, null) - - // RTLM7h: entries with null or older-than-clear serials must be filtered out - assertNull(liveMap.data["key-null-serial"], "Entry with null serial should be filtered by RTLM7h") - assertNull(liveMap.data["key-old-serial"], "Entry with serial1 <= clearTimeserial serial2 should be filtered by RTLM7h") - // Entry whose serial is strictly newer than clearTimeserial must survive - assertNotNull(liveMap.data["key-new-serial"], "Entry with serial3 > clearTimeserial serial2 should be present") - assertEquals("newSerialValue", liveMap.data["key-new-serial"]?.data?.string) - } - - @Test - fun `(RTLM4) clearData resets clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - - liveMap.clearTimeserial = "serial1" - liveMap.clearData() - - assertNull(liveMap.clearTimeserial) - } - - @Test - fun `(RTLM15d8) applyOperation returns true for MAP_CLEAR`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapClear, - objectId = "map:testMap@1", - mapClear = MapClear - ) - - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertTrue(result, "applyOperation should return true for MAP_CLEAR") - } -} From 0f52cca6539437d3f453f5c7dc74045b181d000e Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 24 Jun 2026 17:16:28 +0530 Subject: [PATCH 26/40] Fixed flaky `should_encode_recovery_key_context_object` test by making `RecoveryKeyContext#channelSerials` as a treemap --- lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java b/lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java index c110c9af3..9ccd005a4 100644 --- a/lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java +++ b/lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java @@ -2,8 +2,8 @@ import com.google.gson.JsonSyntaxException; -import java.util.HashMap; import java.util.Map; +import java.util.TreeMap; import io.ably.lib.util.Log; import io.ably.lib.util.Serialisation; @@ -13,7 +13,8 @@ public class RecoveryKeyContext { private final String connectionKey; private final long msgSerial; - private final Map channelSerials = new HashMap<>(); + // Sorted so encode() produces deterministic, key-ordered JSON regardless of input map ordering. + private final Map channelSerials = new TreeMap<>(); public RecoveryKeyContext(String connectionKey, long msgSerial, Map channelSerials) { this.connectionKey = connectionKey; From 23efe8270a1c0c7f26db91458a73caba816b9fcd Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 24 Jun 2026 17:40:42 +0530 Subject: [PATCH 27/40] Refactored object package name to domain specific liveobjects --- .github/workflows/check.yml | 2 +- .github/workflows/integration-test.yml | 2 +- .../java/io/ably/lib/realtime/Channel.java | 2 +- .../LiveObjectsPlugin.java | 10 ++-- .../RealtimeObject.java | 8 ++-- .../{object => liveobjects}/Subscription.java | 2 +- .../{object => liveobjects}/ValueType.java | 2 +- .../adapter/AblyClientAdapter.java | 2 +- .../adapter/Adapter.java | 2 +- .../adapter/package-info.java | 6 +-- .../instance/Instance.java | 20 ++++---- .../instance/InstanceListener.java | 6 +-- .../instance/InstanceSubscriptionEvent.java | 8 ++-- .../liveobjects/instance/package-info.java | 12 +++++ .../instance/types/BinaryInstance.java | 4 +- .../instance/types/BooleanInstance.java | 4 +- .../instance/types/JsonArrayInstance.java | 4 +- .../instance/types/JsonObjectInstance.java | 4 +- .../instance/types/LiveCounterInstance.java | 8 ++-- .../instance/types/LiveMapInstance.java | 10 ++-- .../instance/types/NumberInstance.java | 4 +- .../instance/types/StringInstance.java | 4 +- .../instance/types/package-info.java | 6 +-- .../message/CounterCreate.java | 2 +- .../message/CounterInc.java | 2 +- .../message/MapClear.java | 2 +- .../message/MapCreate.java | 2 +- .../message/MapRemove.java | 2 +- .../message/MapSet.java | 2 +- .../message/ObjectData.java | 2 +- .../message/ObjectDelete.java | 2 +- .../message/ObjectMessage.java | 6 +-- .../message/ObjectOperation.java | 2 +- .../message/ObjectOperationAction.java | 2 +- .../message/ObjectsMapEntry.java | 2 +- .../message/ObjectsMapSemantics.java | 2 +- .../message/package-info.java | 4 +- .../io/ably/lib/liveobjects/package-info.java | 17 +++++++ .../path/PathObject.java | 24 +++++----- .../path/PathObjectListener.java | 2 +- .../path/PathObjectSubscriptionEvent.java | 4 +- .../path/PathObjectSubscriptionOptions.java | 2 +- .../lib/liveobjects/path/package-info.java | 13 +++++ .../path/types/BinaryPathObject.java | 4 +- .../path/types/BooleanPathObject.java | 4 +- .../path/types/JsonArrayPathObject.java | 4 +- .../path/types/JsonObjectPathObject.java | 4 +- .../path/types/LiveCounterPathObject.java | 4 +- .../path/types/LiveMapPathObject.java | 6 +-- .../path/types/NumberPathObject.java | 4 +- .../path/types/StringPathObject.java | 4 +- .../path/types/package-info.java | 6 +-- .../serialization/ObjectJsonSerializer.java | 2 +- .../serialization/ObjectSerializer.java | 4 +- .../state/ObjectStateChange.java | 4 +- .../state/ObjectStateEvent.java | 2 +- .../value/LiveCounter.java | 4 +- .../value/LiveMap.java | 4 +- .../value/LiveMapValue.java | 2 +- .../value/package-info.java | 8 ++-- .../lib/object/instance/package-info.java | 12 ----- .../java/io/ably/lib/object/package-info.java | 17 ------- .../io/ably/lib/object/path/package-info.java | 13 ----- .../io/ably/lib/realtime/AblyRealtime.java | 2 +- .../io/ably/lib/realtime/ChannelBase.java | 9 ++-- .../java/io/ably/lib/realtime/Connection.java | 2 +- .../ably/lib/transport/ConnectionManager.java | 2 +- .../io/ably/lib/types/ProtocolMessage.java | 4 +- liveobjects/build.gradle.kts | 10 ++-- .../DefaultRealtimeObject.kt | 10 ++-- .../lib/{object => liveobjects}/Errors.kt | 2 +- .../lib/{object => liveobjects}/Helpers.kt | 8 ++-- .../ably/lib/{object => liveobjects}/Utils.kt | 2 +- .../instance/DefaultInstance.kt | 22 ++++----- .../DefaultInstanceSubscriptionEvent.kt | 4 +- .../instance/types/DefaultBinaryInstance.kt | 8 ++-- .../instance/types/DefaultBooleanInstance.kt | 8 ++-- .../types/DefaultJsonArrayInstance.kt | 8 ++-- .../types/DefaultJsonObjectInstance.kt | 8 ++-- .../types/DefaultLiveCounterInstance.kt | 14 +++--- .../instance/types/DefaultLiveMapInstance.kt | 18 +++---- .../instance/types/DefaultNumberInstance.kt | 8 ++-- .../instance/types/DefaultStringInstance.kt | 8 ++-- .../message/DefaultObjectMessage.kt | 4 +- .../message/WireObjectMessage.kt | 8 ++-- .../path/DefaultPathObject.kt | 48 +++++++++---------- .../DefaultPathObjectSubscriptionEvent.kt | 4 +- .../path/types/DefaultBinaryPathObject.kt | 10 ++-- .../path/types/DefaultBooleanPathObject.kt | 10 ++-- .../path/types/DefaultJsonArrayPathObject.kt | 10 ++-- .../path/types/DefaultJsonObjectPathObject.kt | 10 ++-- .../types/DefaultLiveCounterPathObject.kt | 12 ++--- .../path/types/DefaultLiveMapPathObject.kt | 16 +++---- .../path/types/DefaultNumberPathObject.kt | 10 ++-- .../path/types/DefaultStringPathObject.kt | 10 ++-- .../serialization/DefaultSerialization.kt | 4 +- .../serialization/JsonSerialization.kt | 10 ++-- .../serialization/MsgpackSerialization.kt | 40 ++++++++-------- .../value/DefaultLiveCounter.kt | 2 +- .../value/DefaultLiveMap.kt | 2 +- .../value/ResolvedValue.kt | 6 +-- .../lib/{object => liveobjects}/TestUtils.kt | 2 +- .../integration/DefaultRealtimeObjectTest.kt | 6 +-- .../integration/helpers/PayloadBuilder.kt | 10 ++-- .../integration/helpers/RestObjects.kt | 6 +-- .../helpers/fixtures/CounterFixtures.kt | 4 +- .../helpers/fixtures/DataFixtures.kt | 4 +- .../helpers/fixtures/MapFixtures.kt | 6 +-- .../integration/setup/IntegrationTest.kt | 4 +- .../integration/setup/Sandbox.kt | 6 +-- .../unit/HelpersTest.kt | 12 ++--- .../unit/ObjectMessageSerializationTest.kt | 16 +++---- .../unit/ObjectMessageSizeTest.kt | 38 +++++++-------- .../unit/TestHelpers.kt | 8 ++-- .../{object => liveobjects}/unit/UtilsTest.kt | 14 +++--- .../unit/fixtures/ObjectMessageFixtures.kt | 26 +++++----- 116 files changed, 436 insertions(+), 439 deletions(-) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/LiveObjectsPlugin.java (94%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/RealtimeObject.java (94%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/Subscription.java (95%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/ValueType.java (96%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/adapter/AblyClientAdapter.java (97%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/adapter/Adapter.java (97%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/adapter/package-info.java (56%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/instance/Instance.java (91%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/instance/InstanceListener.java (73%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/instance/InstanceSubscriptionEvent.java (81%) create mode 100644 lib/src/main/java/io/ably/lib/liveobjects/instance/package-info.java rename lib/src/main/java/io/ably/lib/{object => liveobjects}/instance/types/BinaryInstance.java (90%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/instance/types/BooleanInstance.java (90%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/instance/types/JsonArrayInstance.java (90%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/instance/types/JsonObjectInstance.java (90%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/instance/types/LiveCounterInstance.java (94%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/instance/types/LiveMapInstance.java (94%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/instance/types/NumberInstance.java (90%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/instance/types/StringInstance.java (90%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/instance/types/package-info.java (60%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/message/CounterCreate.java (91%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/message/CounterInc.java (92%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/message/MapClear.java (88%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/message/MapCreate.java (95%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/message/MapRemove.java (90%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/message/MapSet.java (92%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/message/ObjectData.java (97%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/message/ObjectDelete.java (89%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/message/ObjectMessage.java (95%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/message/ObjectOperation.java (98%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/message/ObjectOperationAction.java (95%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/message/ObjectsMapEntry.java (96%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/message/ObjectsMapSemantics.java (89%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/message/package-info.java (89%) create mode 100644 lib/src/main/java/io/ably/lib/liveobjects/package-info.java rename lib/src/main/java/io/ably/lib/{object => liveobjects}/path/PathObject.java (93%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/path/PathObjectListener.java (94%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/path/PathObjectSubscriptionEvent.java (91%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/path/PathObjectSubscriptionOptions.java (97%) create mode 100644 lib/src/main/java/io/ably/lib/liveobjects/path/package-info.java rename lib/src/main/java/io/ably/lib/{object => liveobjects}/path/types/BinaryPathObject.java (90%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/path/types/BooleanPathObject.java (90%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/path/types/JsonArrayPathObject.java (90%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/path/types/JsonObjectPathObject.java (90%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/path/types/LiveCounterPathObject.java (97%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/path/types/LiveMapPathObject.java (97%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/path/types/NumberPathObject.java (90%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/path/types/StringPathObject.java (90%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/path/types/package-info.java (54%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/serialization/ObjectJsonSerializer.java (97%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/serialization/ObjectSerializer.java (97%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/state/ObjectStateChange.java (96%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/state/ObjectStateEvent.java (93%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/value/LiveCounter.java (97%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/value/LiveMap.java (97%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/value/LiveMapValue.java (99%) rename lib/src/main/java/io/ably/lib/{object => liveobjects}/value/package-info.java (68%) delete mode 100644 lib/src/main/java/io/ably/lib/object/instance/package-info.java delete mode 100644 lib/src/main/java/io/ably/lib/object/package-info.java delete mode 100644 lib/src/main/java/io/ably/lib/object/path/package-info.java rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/DefaultRealtimeObject.kt (84%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/Errors.kt (98%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/Helpers.kt (97%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/Utils.kt (98%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/instance/DefaultInstance.kt (73%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/instance/DefaultInstanceSubscriptionEvent.kt (84%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/instance/types/DefaultBinaryInstance.kt (78%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/instance/types/DefaultBooleanInstance.kt (78%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/instance/types/DefaultJsonArrayInstance.kt (79%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/instance/types/DefaultJsonObjectInstance.kt (79%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/instance/types/DefaultLiveCounterInstance.kt (83%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/instance/types/DefaultLiveMapInstance.kt (81%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/instance/types/DefaultNumberInstance.kt (78%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/instance/types/DefaultStringInstance.kt (78%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/message/DefaultObjectMessage.kt (98%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/message/WireObjectMessage.kt (97%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/path/DefaultPathObject.kt (69%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/path/DefaultPathObjectSubscriptionEvent.kt (85%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/path/types/DefaultBinaryPathObject.kt (74%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/path/types/DefaultBooleanPathObject.kt (73%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/path/types/DefaultJsonArrayPathObject.kt (75%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/path/types/DefaultJsonObjectPathObject.kt (75%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/path/types/DefaultLiveCounterPathObject.kt (89%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/path/types/DefaultLiveMapPathObject.kt (87%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/path/types/DefaultNumberPathObject.kt (73%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/path/types/DefaultStringPathObject.kt (73%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/serialization/DefaultSerialization.kt (93%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/serialization/JsonSerialization.kt (91%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/serialization/MsgpackSerialization.kt (95%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/value/DefaultLiveCounter.kt (95%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/value/DefaultLiveMap.kt (96%) rename liveobjects/src/main/kotlin/io/ably/lib/{object => liveobjects}/value/ResolvedValue.kt (91%) rename liveobjects/src/test/kotlin/io/ably/lib/{object => liveobjects}/TestUtils.kt (98%) rename liveobjects/src/test/kotlin/io/ably/lib/{object => liveobjects}/integration/DefaultRealtimeObjectTest.kt (90%) rename liveobjects/src/test/kotlin/io/ably/lib/{object => liveobjects}/integration/helpers/PayloadBuilder.kt (92%) rename liveobjects/src/test/kotlin/io/ably/lib/{object => liveobjects}/integration/helpers/RestObjects.kt (96%) rename liveobjects/src/test/kotlin/io/ably/lib/{object => liveobjects}/integration/helpers/fixtures/CounterFixtures.kt (96%) rename liveobjects/src/test/kotlin/io/ably/lib/{object => liveobjects}/integration/helpers/fixtures/DataFixtures.kt (96%) rename liveobjects/src/test/kotlin/io/ably/lib/{object => liveobjects}/integration/helpers/fixtures/MapFixtures.kt (97%) rename liveobjects/src/test/kotlin/io/ably/lib/{object => liveobjects}/integration/setup/IntegrationTest.kt (96%) rename liveobjects/src/test/kotlin/io/ably/lib/{object => liveobjects}/integration/setup/Sandbox.kt (94%) rename liveobjects/src/test/kotlin/io/ably/lib/{object => liveobjects}/unit/HelpersTest.kt (98%) rename liveobjects/src/test/kotlin/io/ably/lib/{object => liveobjects}/unit/ObjectMessageSerializationTest.kt (92%) rename liveobjects/src/test/kotlin/io/ably/lib/{object => liveobjects}/unit/ObjectMessageSizeTest.kt (87%) rename liveobjects/src/test/kotlin/io/ably/lib/{object => liveobjects}/unit/TestHelpers.kt (89%) rename liveobjects/src/test/kotlin/io/ably/lib/{object => liveobjects}/unit/UtilsTest.kt (91%) rename liveobjects/src/test/kotlin/io/ably/lib/{object => liveobjects}/unit/fixtures/ObjectMessageFixtures.kt (88%) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 5017a0732..c60db1d41 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -23,4 +23,4 @@ jobs: distribution: 'temurin' - name: Set up Gradle uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 - - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectUnitTests :uts:test + - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectsUnitTests :uts:test diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 1c59f771a..8250c30aa 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -122,4 +122,4 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 - - run: ./gradlew runLiveObjectIntegrationTests + - run: ./gradlew runLiveObjectsIntegrationTests diff --git a/java/src/main/java/io/ably/lib/realtime/Channel.java b/java/src/main/java/io/ably/lib/realtime/Channel.java index e3f21de54..f4100162f 100644 --- a/java/src/main/java/io/ably/lib/realtime/Channel.java +++ b/java/src/main/java/io/ably/lib/realtime/Channel.java @@ -1,6 +1,6 @@ package io.ably.lib.realtime; -import io.ably.lib.object.LiveObjectsPlugin; +import io.ably.lib.liveobjects.LiveObjectsPlugin; import io.ably.lib.types.AblyException; import io.ably.lib.types.ChannelOptions; import org.jetbrains.annotations.Nullable; diff --git a/lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java b/lib/src/main/java/io/ably/lib/liveobjects/LiveObjectsPlugin.java similarity index 94% rename from lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java rename to lib/src/main/java/io/ably/lib/liveobjects/LiveObjectsPlugin.java index 4c92d7692..41ab0622c 100644 --- a/lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/LiveObjectsPlugin.java @@ -1,7 +1,7 @@ -package io.ably.lib.object; +package io.ably.lib.liveobjects; -import io.ably.lib.object.adapter.AblyClientAdapter; -import io.ably.lib.object.adapter.Adapter; +import io.ably.lib.liveobjects.adapter.AblyClientAdapter; +import io.ably.lib.liveobjects.adapter.Adapter; import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.ChannelState; import io.ably.lib.types.ProtocolMessage; @@ -81,12 +81,12 @@ static LiveObjectsPlugin tryInitialize(@NotNull AblyRealtime ablyRealtime) { /** * Reflectively constructs the LiveObjects plugin implementation. Lives in a nested class so the * implementation-class name stays {@code private} (interface fields are forced {@code public}), - * mirroring {@link io.ably.lib.object.serialization.ObjectSerializer.Holder}. Unlike {@code Holder} + * mirroring {@link io.ably.lib.liveobjects.serialization.ObjectSerializer.Holder}. Unlike {@code Holder} * this is stateless: {@link #create} returns a new instance on every call. */ final class Factory { private static final String TAG = LiveObjectsPlugin.Factory.class.getName(); - private static final String IMPLEMENTATION_CLASS = "io.ably.lib.object.DefaultLiveObjectsPlugin"; + private static final String IMPLEMENTATION_CLASS = "io.ably.lib.liveobjects.DefaultLiveObjectsPlugin"; private Factory() {} diff --git a/lib/src/main/java/io/ably/lib/object/RealtimeObject.java b/lib/src/main/java/io/ably/lib/liveobjects/RealtimeObject.java similarity index 94% rename from lib/src/main/java/io/ably/lib/object/RealtimeObject.java rename to lib/src/main/java/io/ably/lib/liveobjects/RealtimeObject.java index 013a4a649..e9dc90a7d 100644 --- a/lib/src/main/java/io/ably/lib/object/RealtimeObject.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/RealtimeObject.java @@ -1,8 +1,8 @@ -package io.ably.lib.object; +package io.ably.lib.liveobjects; -import io.ably.lib.object.path.types.LiveMapPathObject; -import io.ably.lib.object.state.ObjectStateChange; -import io.ably.lib.object.state.ObjectStateEvent; +import io.ably.lib.liveobjects.path.types.LiveMapPathObject; +import io.ably.lib.liveobjects.state.ObjectStateChange; +import io.ably.lib.liveobjects.state.ObjectStateEvent; import io.ably.lib.types.AblyException; import io.ably.lib.types.ErrorInfo; import org.jetbrains.annotations.NotNull; diff --git a/lib/src/main/java/io/ably/lib/object/Subscription.java b/lib/src/main/java/io/ably/lib/liveobjects/Subscription.java similarity index 95% rename from lib/src/main/java/io/ably/lib/object/Subscription.java rename to lib/src/main/java/io/ably/lib/liveobjects/Subscription.java index 0f74a907e..720bf9a79 100644 --- a/lib/src/main/java/io/ably/lib/object/Subscription.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/Subscription.java @@ -1,4 +1,4 @@ -package io.ably.lib.object; +package io.ably.lib.liveobjects; /** * Represents a registration for receiving events from a subscribe operation. diff --git a/lib/src/main/java/io/ably/lib/object/ValueType.java b/lib/src/main/java/io/ably/lib/liveobjects/ValueType.java similarity index 96% rename from lib/src/main/java/io/ably/lib/object/ValueType.java rename to lib/src/main/java/io/ably/lib/liveobjects/ValueType.java index 1491d9c36..ac050adf1 100644 --- a/lib/src/main/java/io/ably/lib/object/ValueType.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/ValueType.java @@ -1,4 +1,4 @@ -package io.ably.lib.object; +package io.ably.lib.liveobjects; /** * The type of a value resolved by a {@code PathObject} or wrapped by an diff --git a/lib/src/main/java/io/ably/lib/object/adapter/AblyClientAdapter.java b/lib/src/main/java/io/ably/lib/liveobjects/adapter/AblyClientAdapter.java similarity index 97% rename from lib/src/main/java/io/ably/lib/object/adapter/AblyClientAdapter.java rename to lib/src/main/java/io/ably/lib/liveobjects/adapter/AblyClientAdapter.java index 3204708b3..6a63a8783 100644 --- a/lib/src/main/java/io/ably/lib/object/adapter/AblyClientAdapter.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/adapter/AblyClientAdapter.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.adapter; +package io.ably.lib.liveobjects.adapter; import io.ably.lib.realtime.ChannelBase; import io.ably.lib.realtime.Connection; diff --git a/lib/src/main/java/io/ably/lib/object/adapter/Adapter.java b/lib/src/main/java/io/ably/lib/liveobjects/adapter/Adapter.java similarity index 97% rename from lib/src/main/java/io/ably/lib/object/adapter/Adapter.java rename to lib/src/main/java/io/ably/lib/liveobjects/adapter/Adapter.java index e16d9b306..a935f68bc 100644 --- a/lib/src/main/java/io/ably/lib/object/adapter/Adapter.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/adapter/Adapter.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.adapter; +package io.ably.lib.liveobjects.adapter; import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.Channel; diff --git a/lib/src/main/java/io/ably/lib/object/adapter/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/adapter/package-info.java similarity index 56% rename from lib/src/main/java/io/ably/lib/object/adapter/package-info.java rename to lib/src/main/java/io/ably/lib/liveobjects/adapter/package-info.java index c1589741b..3539eba35 100644 --- a/lib/src/main/java/io/ably/lib/object/adapter/package-info.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/adapter/package-info.java @@ -1,10 +1,10 @@ /** * Adapter layer bridging the path-based LiveObjects implementation to the core Ably client. - * {@link io.ably.lib.object.adapter.AblyClientAdapter} is the abstraction the implementation - * depends on; {@link io.ably.lib.object.adapter.Adapter} is the default implementation backed + * {@link io.ably.lib.liveobjects.adapter.AblyClientAdapter} is the abstraction the implementation + * depends on; {@link io.ably.lib.liveobjects.adapter.Adapter} is the default implementation backed * by an {@link io.ably.lib.realtime.AblyRealtime} client. * *

This package is intentionally independent of the legacy {@code io.ably.lib.objects} * package so the path-based API can evolve on its own. */ -package io.ably.lib.object.adapter; +package io.ably.lib.liveobjects.adapter; diff --git a/lib/src/main/java/io/ably/lib/object/instance/Instance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/Instance.java similarity index 91% rename from lib/src/main/java/io/ably/lib/object/instance/Instance.java rename to lib/src/main/java/io/ably/lib/liveobjects/instance/Instance.java index c29cadab4..a364e9b3b 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/Instance.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/Instance.java @@ -1,15 +1,15 @@ -package io.ably.lib.object.instance; +package io.ably.lib.liveobjects.instance; import com.google.gson.JsonElement; -import io.ably.lib.object.ValueType; -import io.ably.lib.object.instance.types.BinaryInstance; -import io.ably.lib.object.instance.types.BooleanInstance; -import io.ably.lib.object.instance.types.JsonArrayInstance; -import io.ably.lib.object.instance.types.JsonObjectInstance; -import io.ably.lib.object.instance.types.LiveCounterInstance; -import io.ably.lib.object.instance.types.LiveMapInstance; -import io.ably.lib.object.instance.types.NumberInstance; -import io.ably.lib.object.instance.types.StringInstance; +import io.ably.lib.liveobjects.ValueType; +import io.ably.lib.liveobjects.instance.types.BinaryInstance; +import io.ably.lib.liveobjects.instance.types.BooleanInstance; +import io.ably.lib.liveobjects.instance.types.JsonArrayInstance; +import io.ably.lib.liveobjects.instance.types.JsonObjectInstance; +import io.ably.lib.liveobjects.instance.types.LiveCounterInstance; +import io.ably.lib.liveobjects.instance.types.LiveMapInstance; +import io.ably.lib.liveobjects.instance.types.NumberInstance; +import io.ably.lib.liveobjects.instance.types.StringInstance; import org.jetbrains.annotations.NotNull; /** diff --git a/lib/src/main/java/io/ably/lib/object/instance/InstanceListener.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/InstanceListener.java similarity index 73% rename from lib/src/main/java/io/ably/lib/object/instance/InstanceListener.java rename to lib/src/main/java/io/ably/lib/liveobjects/instance/InstanceListener.java index fe069e7db..942c0635b 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/InstanceListener.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/InstanceListener.java @@ -1,7 +1,7 @@ -package io.ably.lib.object.instance; +package io.ably.lib.liveobjects.instance; -import io.ably.lib.object.instance.types.LiveCounterInstance; -import io.ably.lib.object.instance.types.LiveMapInstance; +import io.ably.lib.liveobjects.instance.types.LiveCounterInstance; +import io.ably.lib.liveobjects.instance.types.LiveMapInstance; import org.jetbrains.annotations.NotNull; /** diff --git a/lib/src/main/java/io/ably/lib/object/instance/InstanceSubscriptionEvent.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/InstanceSubscriptionEvent.java similarity index 81% rename from lib/src/main/java/io/ably/lib/object/instance/InstanceSubscriptionEvent.java rename to lib/src/main/java/io/ably/lib/liveobjects/instance/InstanceSubscriptionEvent.java index c87526a9b..db460035b 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/InstanceSubscriptionEvent.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/InstanceSubscriptionEvent.java @@ -1,8 +1,8 @@ -package io.ably.lib.object.instance; +package io.ably.lib.liveobjects.instance; -import io.ably.lib.object.instance.types.LiveCounterInstance; -import io.ably.lib.object.instance.types.LiveMapInstance; -import io.ably.lib.object.message.ObjectMessage; +import io.ably.lib.liveobjects.instance.types.LiveCounterInstance; +import io.ably.lib.liveobjects.instance.types.LiveMapInstance; +import io.ably.lib.liveobjects.message.ObjectMessage; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; diff --git a/lib/src/main/java/io/ably/lib/liveobjects/instance/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/package-info.java new file mode 100644 index 000000000..df272f592 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/package-info.java @@ -0,0 +1,12 @@ +/** + * The identity-addressed view of the LiveObjects graph. + * {@link io.ably.lib.liveobjects.instance.Instance} wraps a specific resolved + * LiveObject or primitive value and dereferences it in O(1), following the + * object wherever it sits in the graph. Type-specific operations live on the + * sub-types in {@link io.ably.lib.liveobjects.instance.types}; instance + * subscriptions use {@link io.ably.lib.liveobjects.instance.InstanceListener} and + * {@link io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent}. + * + *

Spec: RTINS1-RTINS16, RTTS7-RTTS9 + */ +package io.ably.lib.liveobjects.instance; diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/BinaryInstance.java similarity index 90% rename from lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java rename to lib/src/main/java/io/ably/lib/liveobjects/instance/types/BinaryInstance.java index f4860d1ae..e7564df31 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/BinaryInstance.java @@ -1,7 +1,7 @@ -package io.ably.lib.object.instance.types; +package io.ably.lib.liveobjects.instance.types; import com.google.gson.JsonPrimitive; -import io.ably.lib.object.instance.Instance; +import io.ably.lib.liveobjects.instance.Instance; import org.jetbrains.annotations.NotNull; /** diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/BooleanInstance.java similarity index 90% rename from lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java rename to lib/src/main/java/io/ably/lib/liveobjects/instance/types/BooleanInstance.java index 380a17812..90eebf425 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/BooleanInstance.java @@ -1,7 +1,7 @@ -package io.ably.lib.object.instance.types; +package io.ably.lib.liveobjects.instance.types; import com.google.gson.JsonPrimitive; -import io.ably.lib.object.instance.Instance; +import io.ably.lib.liveobjects.instance.Instance; import org.jetbrains.annotations.NotNull; /** diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/JsonArrayInstance.java similarity index 90% rename from lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java rename to lib/src/main/java/io/ably/lib/liveobjects/instance/types/JsonArrayInstance.java index 7df1f929f..ce916e907 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/JsonArrayInstance.java @@ -1,7 +1,7 @@ -package io.ably.lib.object.instance.types; +package io.ably.lib.liveobjects.instance.types; import com.google.gson.JsonArray; -import io.ably.lib.object.instance.Instance; +import io.ably.lib.liveobjects.instance.Instance; import org.jetbrains.annotations.NotNull; /** diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/JsonObjectInstance.java similarity index 90% rename from lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java rename to lib/src/main/java/io/ably/lib/liveobjects/instance/types/JsonObjectInstance.java index 07222a11d..724fe5242 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/JsonObjectInstance.java @@ -1,7 +1,7 @@ -package io.ably.lib.object.instance.types; +package io.ably.lib.liveobjects.instance.types; import com.google.gson.JsonObject; -import io.ably.lib.object.instance.Instance; +import io.ably.lib.liveobjects.instance.Instance; import org.jetbrains.annotations.NotNull; /** diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/LiveCounterInstance.java similarity index 94% rename from lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java rename to lib/src/main/java/io/ably/lib/liveobjects/instance/types/LiveCounterInstance.java index f5296ccf9..dd2ba8a51 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/LiveCounterInstance.java @@ -1,9 +1,9 @@ -package io.ably.lib.object.instance.types; +package io.ably.lib.liveobjects.instance.types; import com.google.gson.JsonPrimitive; -import io.ably.lib.object.instance.Instance; -import io.ably.lib.object.instance.InstanceListener; -import io.ably.lib.object.Subscription; +import io.ably.lib.liveobjects.instance.Instance; +import io.ably.lib.liveobjects.instance.InstanceListener; +import io.ably.lib.liveobjects.Subscription; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/LiveMapInstance.java similarity index 94% rename from lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java rename to lib/src/main/java/io/ably/lib/liveobjects/instance/types/LiveMapInstance.java index c5b79bc1c..6e7c21e47 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/LiveMapInstance.java @@ -1,10 +1,10 @@ -package io.ably.lib.object.instance.types; +package io.ably.lib.liveobjects.instance.types; import com.google.gson.JsonObject; -import io.ably.lib.object.instance.Instance; -import io.ably.lib.object.instance.InstanceListener; -import io.ably.lib.object.Subscription; -import io.ably.lib.object.value.LiveMapValue; +import io.ably.lib.liveobjects.instance.Instance; +import io.ably.lib.liveobjects.instance.InstanceListener; +import io.ably.lib.liveobjects.Subscription; +import io.ably.lib.liveobjects.value.LiveMapValue; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/NumberInstance.java similarity index 90% rename from lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java rename to lib/src/main/java/io/ably/lib/liveobjects/instance/types/NumberInstance.java index 298fd59f5..f18cb3c74 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/NumberInstance.java @@ -1,7 +1,7 @@ -package io.ably.lib.object.instance.types; +package io.ably.lib.liveobjects.instance.types; import com.google.gson.JsonPrimitive; -import io.ably.lib.object.instance.Instance; +import io.ably.lib.liveobjects.instance.Instance; import org.jetbrains.annotations.NotNull; /** diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/StringInstance.java similarity index 90% rename from lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java rename to lib/src/main/java/io/ably/lib/liveobjects/instance/types/StringInstance.java index a7a06de15..680f0c3ae 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/StringInstance.java @@ -1,7 +1,7 @@ -package io.ably.lib.object.instance.types; +package io.ably.lib.liveobjects.instance.types; import com.google.gson.JsonPrimitive; -import io.ably.lib.object.instance.Instance; +import io.ably.lib.liveobjects.instance.Instance; import org.jetbrains.annotations.NotNull; /** diff --git a/lib/src/main/java/io/ably/lib/object/instance/types/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/package-info.java similarity index 60% rename from lib/src/main/java/io/ably/lib/object/instance/types/package-info.java rename to lib/src/main/java/io/ably/lib/liveobjects/instance/types/package-info.java index 2ec45e8fd..651f45669 100644 --- a/lib/src/main/java/io/ably/lib/object/instance/types/package-info.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/package-info.java @@ -1,11 +1,11 @@ /** * Type-specific {@code Instance} sub-types: the typed-SDK partition of instance - * operations. {@link io.ably.lib.object.instance.types.LiveMapInstance} + * operations. {@link io.ably.lib.liveobjects.instance.types.LiveMapInstance} * (RTTS10a) carries map reads, writes and subscribe, - * {@link io.ably.lib.object.instance.types.LiveCounterInstance} (RTTS10b) + * {@link io.ably.lib.liveobjects.instance.types.LiveCounterInstance} (RTTS10b) * carries counter operations and subscribe, and the six primitive sub-types * (RTTS10c) expose only a type-narrowed, non-null {@code value()}. * *

Spec: RTTS10 */ -package io.ably.lib.object.instance.types; +package io.ably.lib.liveobjects.instance.types; diff --git a/lib/src/main/java/io/ably/lib/object/message/CounterCreate.java b/lib/src/main/java/io/ably/lib/liveobjects/message/CounterCreate.java similarity index 91% rename from lib/src/main/java/io/ably/lib/object/message/CounterCreate.java rename to lib/src/main/java/io/ably/lib/liveobjects/message/CounterCreate.java index 2d8f5a203..ca05e0e28 100644 --- a/lib/src/main/java/io/ably/lib/object/message/CounterCreate.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/CounterCreate.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.message; +package io.ably.lib.liveobjects.message; import org.jetbrains.annotations.NotNull; diff --git a/lib/src/main/java/io/ably/lib/object/message/CounterInc.java b/lib/src/main/java/io/ably/lib/liveobjects/message/CounterInc.java similarity index 92% rename from lib/src/main/java/io/ably/lib/object/message/CounterInc.java rename to lib/src/main/java/io/ably/lib/liveobjects/message/CounterInc.java index fa1eeee82..88fe59174 100644 --- a/lib/src/main/java/io/ably/lib/object/message/CounterInc.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/CounterInc.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.message; +package io.ably.lib.liveobjects.message; import org.jetbrains.annotations.NotNull; diff --git a/lib/src/main/java/io/ably/lib/object/message/MapClear.java b/lib/src/main/java/io/ably/lib/liveobjects/message/MapClear.java similarity index 88% rename from lib/src/main/java/io/ably/lib/object/message/MapClear.java rename to lib/src/main/java/io/ably/lib/liveobjects/message/MapClear.java index 28609f247..29c9092dc 100644 --- a/lib/src/main/java/io/ably/lib/object/message/MapClear.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/MapClear.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.message; +package io.ably.lib.liveobjects.message; /** * Payload of a {@link ObjectOperationAction#MAP_CLEAR} operation. This type diff --git a/lib/src/main/java/io/ably/lib/object/message/MapCreate.java b/lib/src/main/java/io/ably/lib/liveobjects/message/MapCreate.java similarity index 95% rename from lib/src/main/java/io/ably/lib/object/message/MapCreate.java rename to lib/src/main/java/io/ably/lib/liveobjects/message/MapCreate.java index 73103a92f..fcd7fc539 100644 --- a/lib/src/main/java/io/ably/lib/object/message/MapCreate.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/MapCreate.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.message; +package io.ably.lib.liveobjects.message; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Unmodifiable; diff --git a/lib/src/main/java/io/ably/lib/object/message/MapRemove.java b/lib/src/main/java/io/ably/lib/liveobjects/message/MapRemove.java similarity index 90% rename from lib/src/main/java/io/ably/lib/object/message/MapRemove.java rename to lib/src/main/java/io/ably/lib/liveobjects/message/MapRemove.java index 51336eb5c..fccb67464 100644 --- a/lib/src/main/java/io/ably/lib/object/message/MapRemove.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/MapRemove.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.message; +package io.ably.lib.liveobjects.message; import org.jetbrains.annotations.NotNull; diff --git a/lib/src/main/java/io/ably/lib/object/message/MapSet.java b/lib/src/main/java/io/ably/lib/liveobjects/message/MapSet.java similarity index 92% rename from lib/src/main/java/io/ably/lib/object/message/MapSet.java rename to lib/src/main/java/io/ably/lib/liveobjects/message/MapSet.java index 742b5290f..6b63e88cf 100644 --- a/lib/src/main/java/io/ably/lib/object/message/MapSet.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/MapSet.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.message; +package io.ably.lib.liveobjects.message; import org.jetbrains.annotations.NotNull; diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectData.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectData.java similarity index 97% rename from lib/src/main/java/io/ably/lib/object/message/ObjectData.java rename to lib/src/main/java/io/ably/lib/liveobjects/message/ObjectData.java index 25fb22f34..b98e53920 100644 --- a/lib/src/main/java/io/ably/lib/object/message/ObjectData.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectData.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.message; +package io.ably.lib.liveobjects.message; import com.google.gson.JsonElement; import org.jetbrains.annotations.Nullable; diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectDelete.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectDelete.java similarity index 89% rename from lib/src/main/java/io/ably/lib/object/message/ObjectDelete.java rename to lib/src/main/java/io/ably/lib/liveobjects/message/ObjectDelete.java index 2ebd52cfa..1f8c8c671 100644 --- a/lib/src/main/java/io/ably/lib/object/message/ObjectDelete.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectDelete.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.message; +package io.ably.lib.liveobjects.message; /** * Payload of an {@link ObjectOperationAction#OBJECT_DELETE} operation. This type diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectMessage.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectMessage.java similarity index 95% rename from lib/src/main/java/io/ably/lib/object/message/ObjectMessage.java rename to lib/src/main/java/io/ably/lib/liveobjects/message/ObjectMessage.java index 36b3f825d..3a2513643 100644 --- a/lib/src/main/java/io/ably/lib/object/message/ObjectMessage.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectMessage.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.message; +package io.ably.lib.liveobjects.message; import com.google.gson.JsonObject; import org.jetbrains.annotations.NotNull; @@ -7,8 +7,8 @@ /** * The user-facing representation of an inbound object message that carried an operation. * It is delivered to subscription listeners (see - * {@link io.ably.lib.object.path.PathObjectSubscriptionEvent} and - * {@link io.ably.lib.object.instance.InstanceSubscriptionEvent}) so that user code can + * {@link io.ably.lib.liveobjects.path.PathObjectSubscriptionEvent} and + * {@link io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent}) so that user code can * inspect the metadata of the message that triggered an object change. * *

An {@code ObjectMessage} always carries an {@link #getOperation() operation}; object diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectOperation.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperation.java similarity index 98% rename from lib/src/main/java/io/ably/lib/object/message/ObjectOperation.java rename to lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperation.java index 52a2d2d1b..d4df9fb00 100644 --- a/lib/src/main/java/io/ably/lib/object/message/ObjectOperation.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperation.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.message; +package io.ably.lib.liveobjects.message; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectOperationAction.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperationAction.java similarity index 95% rename from lib/src/main/java/io/ably/lib/object/message/ObjectOperationAction.java rename to lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperationAction.java index 0d3730ea3..002f289d4 100644 --- a/lib/src/main/java/io/ably/lib/object/message/ObjectOperationAction.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperationAction.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.message; +package io.ably.lib.liveobjects.message; /** * The action of an {@link ObjectOperation}, defining the type of operation that was diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectsMapEntry.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapEntry.java similarity index 96% rename from lib/src/main/java/io/ably/lib/object/message/ObjectsMapEntry.java rename to lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapEntry.java index 0da010f0a..09df91fbb 100644 --- a/lib/src/main/java/io/ably/lib/object/message/ObjectsMapEntry.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapEntry.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.message; +package io.ably.lib.liveobjects.message; import org.jetbrains.annotations.Nullable; diff --git a/lib/src/main/java/io/ably/lib/object/message/ObjectsMapSemantics.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapSemantics.java similarity index 89% rename from lib/src/main/java/io/ably/lib/object/message/ObjectsMapSemantics.java rename to lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapSemantics.java index d5cae3f9b..1fcb7f2aa 100644 --- a/lib/src/main/java/io/ably/lib/object/message/ObjectsMapSemantics.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapSemantics.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.message; +package io.ably.lib.liveobjects.message; /** * The conflict-resolution semantics used by a {@code LiveMap} object. diff --git a/lib/src/main/java/io/ably/lib/object/message/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/message/package-info.java similarity index 89% rename from lib/src/main/java/io/ably/lib/object/message/package-info.java rename to lib/src/main/java/io/ably/lib/liveobjects/message/package-info.java index a90af7614..f3f7c246e 100644 --- a/lib/src/main/java/io/ably/lib/object/message/package-info.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/package-info.java @@ -2,7 +2,7 @@ * User-facing object message metadata, delivered to subscription listeners so * that user code can inspect the operation that triggered an object change. * - *

{@link io.ably.lib.object.message.ObjectMessage} is the single entry point + *

{@link io.ably.lib.liveobjects.message.ObjectMessage} is the single entry point * of this package; every other type is reached by walking its properties: * *

{@code
@@ -23,4 +23,4 @@
  *
  * 

Spec: PAOM1-PAOM3, PAOOP1-PAOOP3 */ -package io.ably.lib.object.message; +package io.ably.lib.liveobjects.message; diff --git a/lib/src/main/java/io/ably/lib/liveobjects/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/package-info.java new file mode 100644 index 000000000..722ac3994 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/package-info.java @@ -0,0 +1,17 @@ +/** + * The public, strongly-typed LiveObjects API: path-based and instance-based views + * over the objects graph on a channel. + * + *

This root package holds the types shared by both view hierarchies: + * {@link io.ably.lib.liveobjects.ValueType} (the categories a resolved value may have) + * and {@link io.ably.lib.liveobjects.Subscription} (the handle returned by every + * {@code subscribe} operation). The hierarchies themselves live in + * {@link io.ably.lib.liveobjects.path} (lazy, path-addressed references) and + * {@link io.ably.lib.liveobjects.instance} (O(1), identity-addressed references); + * message metadata delivered to subscription listeners lives in + * {@link io.ably.lib.liveobjects.message}, and write-side value types in + * {@link io.ably.lib.liveobjects.value}. + * + *

Spec: RTTS1-RTTS10 (typed-SDK public API partition) + */ +package io.ably.lib.liveobjects; diff --git a/lib/src/main/java/io/ably/lib/object/path/PathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObject.java similarity index 93% rename from lib/src/main/java/io/ably/lib/object/path/PathObject.java rename to lib/src/main/java/io/ably/lib/liveobjects/path/PathObject.java index 5e084e04d..36a2ec49d 100644 --- a/lib/src/main/java/io/ably/lib/object/path/PathObject.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObject.java @@ -1,17 +1,17 @@ -package io.ably.lib.object.path; +package io.ably.lib.liveobjects.path; import com.google.gson.JsonElement; -import io.ably.lib.object.ValueType; -import io.ably.lib.object.instance.Instance; -import io.ably.lib.object.path.types.BinaryPathObject; -import io.ably.lib.object.path.types.BooleanPathObject; -import io.ably.lib.object.path.types.JsonArrayPathObject; -import io.ably.lib.object.path.types.JsonObjectPathObject; -import io.ably.lib.object.path.types.LiveCounterPathObject; -import io.ably.lib.object.path.types.LiveMapPathObject; -import io.ably.lib.object.path.types.NumberPathObject; -import io.ably.lib.object.path.types.StringPathObject; -import io.ably.lib.object.Subscription; +import io.ably.lib.liveobjects.ValueType; +import io.ably.lib.liveobjects.instance.Instance; +import io.ably.lib.liveobjects.path.types.BinaryPathObject; +import io.ably.lib.liveobjects.path.types.BooleanPathObject; +import io.ably.lib.liveobjects.path.types.JsonArrayPathObject; +import io.ably.lib.liveobjects.path.types.JsonObjectPathObject; +import io.ably.lib.liveobjects.path.types.LiveCounterPathObject; +import io.ably.lib.liveobjects.path.types.LiveMapPathObject; +import io.ably.lib.liveobjects.path.types.NumberPathObject; +import io.ably.lib.liveobjects.path.types.StringPathObject; +import io.ably.lib.liveobjects.Subscription; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; diff --git a/lib/src/main/java/io/ably/lib/object/path/PathObjectListener.java b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectListener.java similarity index 94% rename from lib/src/main/java/io/ably/lib/object/path/PathObjectListener.java rename to lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectListener.java index 895e4ad2f..48bca1c72 100644 --- a/lib/src/main/java/io/ably/lib/object/path/PathObjectListener.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectListener.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.path; +package io.ably.lib.liveobjects.path; import org.jetbrains.annotations.NotNull; diff --git a/lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionEvent.java b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionEvent.java similarity index 91% rename from lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionEvent.java rename to lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionEvent.java index a8c753c70..7fd978de9 100644 --- a/lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionEvent.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionEvent.java @@ -1,6 +1,6 @@ -package io.ably.lib.object.path; +package io.ably.lib.liveobjects.path; -import io.ably.lib.object.message.ObjectMessage; +import io.ably.lib.liveobjects.message.ObjectMessage; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; diff --git a/lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionOptions.java b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionOptions.java similarity index 97% rename from lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionOptions.java rename to lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionOptions.java index cf83c3ae4..df9b5a9fa 100644 --- a/lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionOptions.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionOptions.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.path; +package io.ably.lib.liveobjects.path; import io.ably.lib.types.AblyException; import io.ably.lib.types.ErrorInfo; diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/path/package-info.java new file mode 100644 index 000000000..00471f2fe --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/package-info.java @@ -0,0 +1,13 @@ +/** + * The path-addressed view of the LiveObjects graph. + * {@link io.ably.lib.liveobjects.path.PathObject} stores a path from the channel's + * root {@code LiveMap} and re-resolves it lazily on every call, so a reference + * survives object replacement at its path. Type-specific operations live on the + * sub-types in {@link io.ably.lib.liveobjects.path.types}; path-based subscriptions + * use {@link io.ably.lib.liveobjects.path.PathObjectListener}, + * {@link io.ably.lib.liveobjects.path.PathObjectSubscriptionEvent} and + * {@link io.ably.lib.liveobjects.path.PathObjectSubscriptionOptions}. + * + *

Spec: RTPO1-RTPO19, RTTS3-RTTS5 + */ +package io.ably.lib.liveobjects.path; diff --git a/lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/BinaryPathObject.java similarity index 90% rename from lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java rename to lib/src/main/java/io/ably/lib/liveobjects/path/types/BinaryPathObject.java index f47765cea..e96ac7062 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/BinaryPathObject.java @@ -1,6 +1,6 @@ -package io.ably.lib.object.path.types; +package io.ably.lib.liveobjects.path.types; -import io.ably.lib.object.path.PathObject; +import io.ably.lib.liveobjects.path.PathObject; import org.jetbrains.annotations.Nullable; /** diff --git a/lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/BooleanPathObject.java similarity index 90% rename from lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java rename to lib/src/main/java/io/ably/lib/liveobjects/path/types/BooleanPathObject.java index b582227c8..c77c59f9e 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/BooleanPathObject.java @@ -1,6 +1,6 @@ -package io.ably.lib.object.path.types; +package io.ably.lib.liveobjects.path.types; -import io.ably.lib.object.path.PathObject; +import io.ably.lib.liveobjects.path.PathObject; import org.jetbrains.annotations.Nullable; /** diff --git a/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonArrayPathObject.java similarity index 90% rename from lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java rename to lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonArrayPathObject.java index 585980bf8..52a89f016 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonArrayPathObject.java @@ -1,7 +1,7 @@ -package io.ably.lib.object.path.types; +package io.ably.lib.liveobjects.path.types; import com.google.gson.JsonArray; -import io.ably.lib.object.path.PathObject; +import io.ably.lib.liveobjects.path.PathObject; import org.jetbrains.annotations.Nullable; /** diff --git a/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonObjectPathObject.java similarity index 90% rename from lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java rename to lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonObjectPathObject.java index 681fcaa6e..b889ab521 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonObjectPathObject.java @@ -1,7 +1,7 @@ -package io.ably.lib.object.path.types; +package io.ably.lib.liveobjects.path.types; import com.google.gson.JsonObject; -import io.ably.lib.object.path.PathObject; +import io.ably.lib.liveobjects.path.PathObject; import org.jetbrains.annotations.Nullable; /** diff --git a/lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveCounterPathObject.java similarity index 97% rename from lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java rename to lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveCounterPathObject.java index bb2588213..fb8eb87d3 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveCounterPathObject.java @@ -1,6 +1,6 @@ -package io.ably.lib.object.path.types; +package io.ably.lib.liveobjects.path.types; -import io.ably.lib.object.path.PathObject; +import io.ably.lib.liveobjects.path.PathObject; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; diff --git a/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveMapPathObject.java similarity index 97% rename from lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java rename to lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveMapPathObject.java index 6c4f0ab00..cf35e553f 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/LiveMapPathObject.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveMapPathObject.java @@ -1,7 +1,7 @@ -package io.ably.lib.object.path.types; +package io.ably.lib.liveobjects.path.types; -import io.ably.lib.object.path.PathObject; -import io.ably.lib.object.value.LiveMapValue; +import io.ably.lib.liveobjects.path.PathObject; +import io.ably.lib.liveobjects.value.LiveMapValue; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; diff --git a/lib/src/main/java/io/ably/lib/object/path/types/NumberPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/NumberPathObject.java similarity index 90% rename from lib/src/main/java/io/ably/lib/object/path/types/NumberPathObject.java rename to lib/src/main/java/io/ably/lib/liveobjects/path/types/NumberPathObject.java index 3903004fa..6eadd3697 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/NumberPathObject.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/NumberPathObject.java @@ -1,6 +1,6 @@ -package io.ably.lib.object.path.types; +package io.ably.lib.liveobjects.path.types; -import io.ably.lib.object.path.PathObject; +import io.ably.lib.liveobjects.path.PathObject; import org.jetbrains.annotations.Nullable; /** diff --git a/lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/StringPathObject.java similarity index 90% rename from lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java rename to lib/src/main/java/io/ably/lib/liveobjects/path/types/StringPathObject.java index 06c332994..18652dc03 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/StringPathObject.java @@ -1,6 +1,6 @@ -package io.ably.lib.object.path.types; +package io.ably.lib.liveobjects.path.types; -import io.ably.lib.object.path.PathObject; +import io.ably.lib.liveobjects.path.PathObject; import org.jetbrains.annotations.Nullable; /** diff --git a/lib/src/main/java/io/ably/lib/object/path/types/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/package-info.java similarity index 54% rename from lib/src/main/java/io/ably/lib/object/path/types/package-info.java rename to lib/src/main/java/io/ably/lib/liveobjects/path/types/package-info.java index c97e152dc..1575cfabc 100644 --- a/lib/src/main/java/io/ably/lib/object/path/types/package-info.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/package-info.java @@ -1,11 +1,11 @@ /** * Type-specific {@code PathObject} sub-types: the typed-SDK partition of path - * operations. {@link io.ably.lib.object.path.types.LiveMapPathObject} (RTTS6a) + * operations. {@link io.ably.lib.liveobjects.path.types.LiveMapPathObject} (RTTS6a) * carries map navigation and writes, - * {@link io.ably.lib.object.path.types.LiveCounterPathObject} (RTTS6b) carries + * {@link io.ably.lib.liveobjects.path.types.LiveCounterPathObject} (RTTS6b) carries * counter operations, and the six primitive sub-types (RTTS6c) expose only a * type-narrowed {@code value()}. * *

Spec: RTTS6 */ -package io.ably.lib.object.path.types; +package io.ably.lib.liveobjects.path.types; diff --git a/lib/src/main/java/io/ably/lib/object/serialization/ObjectJsonSerializer.java b/lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectJsonSerializer.java similarity index 97% rename from lib/src/main/java/io/ably/lib/object/serialization/ObjectJsonSerializer.java rename to lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectJsonSerializer.java index 8c8566490..c6f95d200 100644 --- a/lib/src/main/java/io/ably/lib/object/serialization/ObjectJsonSerializer.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectJsonSerializer.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.serialization; +package io.ably.lib.liveobjects.serialization; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; diff --git a/lib/src/main/java/io/ably/lib/object/serialization/ObjectSerializer.java b/lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectSerializer.java similarity index 97% rename from lib/src/main/java/io/ably/lib/object/serialization/ObjectSerializer.java rename to lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectSerializer.java index 78d237104..c5b6abcf3 100644 --- a/lib/src/main/java/io/ably/lib/object/serialization/ObjectSerializer.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectSerializer.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.serialization; +package io.ably.lib.liveobjects.serialization; import com.google.gson.JsonArray; import io.ably.lib.util.Log; @@ -70,7 +70,7 @@ static ObjectSerializer tryGet() { */ final class Holder { private static final String TAG = ObjectSerializer.Holder.class.getName(); - private static final String IMPLEMENTATION_CLASS = "io.ably.lib.object.serialization.DefaultObjectsSerializer"; + private static final String IMPLEMENTATION_CLASS = "io.ably.lib.liveobjects.serialization.DefaultObjectsSerializer"; private static volatile ObjectSerializer objectsSerializer; private Holder() {} diff --git a/lib/src/main/java/io/ably/lib/object/state/ObjectStateChange.java b/lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateChange.java similarity index 96% rename from lib/src/main/java/io/ably/lib/object/state/ObjectStateChange.java rename to lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateChange.java index bf457fddf..d6bb65385 100644 --- a/lib/src/main/java/io/ably/lib/object/state/ObjectStateChange.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateChange.java @@ -1,6 +1,6 @@ -package io.ably.lib.object.state; +package io.ably.lib.liveobjects.state; -import io.ably.lib.object.Subscription; +import io.ably.lib.liveobjects.Subscription; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; diff --git a/lib/src/main/java/io/ably/lib/object/state/ObjectStateEvent.java b/lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateEvent.java similarity index 93% rename from lib/src/main/java/io/ably/lib/object/state/ObjectStateEvent.java rename to lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateEvent.java index 9c9a45045..053d26116 100644 --- a/lib/src/main/java/io/ably/lib/object/state/ObjectStateEvent.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateEvent.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.state; +package io.ably.lib.liveobjects.state; /** * Represents the synchronization state of Ably Objects. diff --git a/lib/src/main/java/io/ably/lib/object/value/LiveCounter.java b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveCounter.java similarity index 97% rename from lib/src/main/java/io/ably/lib/object/value/LiveCounter.java rename to lib/src/main/java/io/ably/lib/liveobjects/value/LiveCounter.java index dfd3b785d..484c1be15 100644 --- a/lib/src/main/java/io/ably/lib/object/value/LiveCounter.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveCounter.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.value; +package io.ably.lib.liveobjects.value; import org.jetbrains.annotations.NotNull; @@ -23,7 +23,7 @@ */ public abstract class LiveCounter { - private static final String IMPLEMENTATION_CLASS = "io.ably.lib.object.value.DefaultLiveCounter"; + private static final String IMPLEMENTATION_CLASS = "io.ably.lib.liveobjects.value.DefaultLiveCounter"; /** * Extended by the LiveObjects implementation; not intended for diff --git a/lib/src/main/java/io/ably/lib/object/value/LiveMap.java b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveMap.java similarity index 97% rename from lib/src/main/java/io/ably/lib/object/value/LiveMap.java rename to lib/src/main/java/io/ably/lib/liveobjects/value/LiveMap.java index c43f76a96..022622d8b 100644 --- a/lib/src/main/java/io/ably/lib/object/value/LiveMap.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveMap.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.value; +package io.ably.lib.liveobjects.value; import org.jetbrains.annotations.NotNull; @@ -26,7 +26,7 @@ */ public abstract class LiveMap { - private static final String IMPLEMENTATION_CLASS = "io.ably.lib.object.value.DefaultLiveMap"; + private static final String IMPLEMENTATION_CLASS = "io.ably.lib.liveobjects.value.DefaultLiveMap"; /** * Extended by the LiveObjects implementation; not intended for diff --git a/lib/src/main/java/io/ably/lib/object/value/LiveMapValue.java b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveMapValue.java similarity index 99% rename from lib/src/main/java/io/ably/lib/object/value/LiveMapValue.java rename to lib/src/main/java/io/ably/lib/liveobjects/value/LiveMapValue.java index 5f80595a5..406e48d02 100644 --- a/lib/src/main/java/io/ably/lib/object/value/LiveMapValue.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveMapValue.java @@ -1,4 +1,4 @@ -package io.ably.lib.object.value; +package io.ably.lib.liveobjects.value; import com.google.gson.JsonArray; import com.google.gson.JsonObject; diff --git a/lib/src/main/java/io/ably/lib/object/value/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/value/package-info.java similarity index 68% rename from lib/src/main/java/io/ably/lib/object/value/package-info.java rename to lib/src/main/java/io/ably/lib/liveobjects/value/package-info.java index 583baa039..6a4e798ec 100644 --- a/lib/src/main/java/io/ably/lib/object/value/package-info.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/value/package-info.java @@ -1,9 +1,9 @@ /** * Write-side value types for LiveObjects mutations. - * {@link io.ably.lib.object.value.LiveMapValue} is the union of values + * {@link io.ably.lib.liveobjects.value.LiveMapValue} is the union of values * assignable to a {@code LiveMap} key; - * {@link io.ably.lib.object.value.LiveMap} and - * {@link io.ably.lib.object.value.LiveCounter} are immutable initial-value + * {@link io.ably.lib.liveobjects.value.LiveMap} and + * {@link io.ably.lib.liveobjects.value.LiveCounter} are immutable initial-value * holders describing new objects to be created by a mutation; they expose only * the static {@code create} factories (RTLMV3 / RTLCV3), which delegate to the * LiveObjects implementation extending these abstract classes. Their internal @@ -13,4 +13,4 @@ *

Spec: RTLM20 / RTPO15a2 / RTINS12a2 (value union); RTLMV3 / RTLCV3 * (new-object value types) */ -package io.ably.lib.object.value; +package io.ably.lib.liveobjects.value; diff --git a/lib/src/main/java/io/ably/lib/object/instance/package-info.java b/lib/src/main/java/io/ably/lib/object/instance/package-info.java deleted file mode 100644 index c99b3f05f..000000000 --- a/lib/src/main/java/io/ably/lib/object/instance/package-info.java +++ /dev/null @@ -1,12 +0,0 @@ -/** - * The identity-addressed view of the LiveObjects graph. - * {@link io.ably.lib.object.instance.Instance} wraps a specific resolved - * LiveObject or primitive value and dereferences it in O(1), following the - * object wherever it sits in the graph. Type-specific operations live on the - * sub-types in {@link io.ably.lib.object.instance.types}; instance - * subscriptions use {@link io.ably.lib.object.instance.InstanceListener} and - * {@link io.ably.lib.object.instance.InstanceSubscriptionEvent}. - * - *

Spec: RTINS1-RTINS16, RTTS7-RTTS9 - */ -package io.ably.lib.object.instance; diff --git a/lib/src/main/java/io/ably/lib/object/package-info.java b/lib/src/main/java/io/ably/lib/object/package-info.java deleted file mode 100644 index 2a8719347..000000000 --- a/lib/src/main/java/io/ably/lib/object/package-info.java +++ /dev/null @@ -1,17 +0,0 @@ -/** - * The public, strongly-typed LiveObjects API: path-based and instance-based views - * over the objects graph on a channel. - * - *

This root package holds the types shared by both view hierarchies: - * {@link io.ably.lib.object.ValueType} (the categories a resolved value may have) - * and {@link io.ably.lib.object.Subscription} (the handle returned by every - * {@code subscribe} operation). The hierarchies themselves live in - * {@link io.ably.lib.object.path} (lazy, path-addressed references) and - * {@link io.ably.lib.object.instance} (O(1), identity-addressed references); - * message metadata delivered to subscription listeners lives in - * {@link io.ably.lib.object.message}, and write-side value types in - * {@link io.ably.lib.object.value}. - * - *

Spec: RTTS1-RTTS10 (typed-SDK public API partition) - */ -package io.ably.lib.object; diff --git a/lib/src/main/java/io/ably/lib/object/path/package-info.java b/lib/src/main/java/io/ably/lib/object/path/package-info.java deleted file mode 100644 index a2414cf6c..000000000 --- a/lib/src/main/java/io/ably/lib/object/path/package-info.java +++ /dev/null @@ -1,13 +0,0 @@ -/** - * The path-addressed view of the LiveObjects graph. - * {@link io.ably.lib.object.path.PathObject} stores a path from the channel's - * root {@code LiveMap} and re-resolves it lazily on every call, so a reference - * survives object replacement at its path. Type-specific operations live on the - * sub-types in {@link io.ably.lib.object.path.types}; path-based subscriptions - * use {@link io.ably.lib.object.path.PathObjectListener}, - * {@link io.ably.lib.object.path.PathObjectSubscriptionEvent} and - * {@link io.ably.lib.object.path.PathObjectSubscriptionOptions}. - * - *

Spec: RTPO1-RTPO19, RTTS3-RTTS5 - */ -package io.ably.lib.object.path; diff --git a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java index 3d1c4a663..b991ed63b 100644 --- a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java +++ b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java @@ -5,7 +5,7 @@ import java.util.List; import java.util.Map; -import io.ably.lib.object.LiveObjectsPlugin; +import io.ably.lib.liveobjects.LiveObjectsPlugin; import io.ably.lib.rest.AblyRest; import io.ably.lib.rest.Auth; import io.ably.lib.transport.ConnectionManager; diff --git a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java index 3b9d4c41f..fe68a481d 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java +++ b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java @@ -13,8 +13,8 @@ import io.ably.lib.http.Http; import io.ably.lib.http.HttpCore; import io.ably.lib.http.HttpUtils; -import io.ably.lib.object.RealtimeObject; -import io.ably.lib.object.LiveObjectsPlugin; +import io.ably.lib.liveobjects.RealtimeObject; +import io.ably.lib.liveobjects.LiveObjectsPlugin; import io.ably.lib.rest.MessageEditsMixin; import io.ably.lib.rest.RestAnnotations; import io.ably.lib.transport.ConnectionManager; @@ -1687,10 +1687,7 @@ else if(stateChange.current.equals(failureState)) { this.decodingContext = new DecodingContext(); this.liveObjectsPlugin = liveObjectsPlugin; if (liveObjectsPlugin != null) { - liveObjectsPlugin.getInstance(name); - // TODO(objects-migration): assign `this.object` to the real RealtimeObject once the - // LiveObjects plugin exposes io.ably.lib.object.RealtimeObject (getInstance currently - // returns the legacy io.ably.lib.objects.RealtimeObjects type). + this.object = liveObjectsPlugin.getInstance(name); } else { this.object = RealtimeObject.Unavailable.INSTANCE; } diff --git a/lib/src/main/java/io/ably/lib/realtime/Connection.java b/lib/src/main/java/io/ably/lib/realtime/Connection.java index 8f0898550..dde9238d0 100644 --- a/lib/src/main/java/io/ably/lib/realtime/Connection.java +++ b/lib/src/main/java/io/ably/lib/realtime/Connection.java @@ -1,6 +1,6 @@ package io.ably.lib.realtime; -import io.ably.lib.object.LiveObjectsPlugin; +import io.ably.lib.liveobjects.LiveObjectsPlugin; import io.ably.lib.realtime.ConnectionStateListener.ConnectionStateChange; import io.ably.lib.transport.ConnectionManager; import io.ably.lib.types.AblyException; diff --git a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java index 9b76f628a..15f3b31ab 100644 --- a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java +++ b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java @@ -14,7 +14,7 @@ import io.ably.lib.debug.DebugOptions; import io.ably.lib.debug.DebugOptions.RawProtocolListener; import io.ably.lib.http.HttpHelpers; -import io.ably.lib.object.LiveObjectsPlugin; +import io.ably.lib.liveobjects.LiveObjectsPlugin; import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.Channel; import io.ably.lib.realtime.ChannelState; diff --git a/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java b/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java index ff86edd52..bab089c6e 100644 --- a/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java +++ b/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java @@ -5,8 +5,8 @@ import java.util.Map; import com.google.gson.annotations.JsonAdapter; -import io.ably.lib.object.serialization.ObjectJsonSerializer; -import io.ably.lib.object.serialization.ObjectSerializer; +import io.ably.lib.liveobjects.serialization.ObjectJsonSerializer; +import io.ably.lib.liveobjects.serialization.ObjectSerializer; import org.jetbrains.annotations.Nullable; import org.msgpack.core.MessageFormat; import org.msgpack.core.MessagePacker; diff --git a/liveobjects/build.gradle.kts b/liveobjects/build.gradle.kts index e8ef19da3..9d6ad9420 100644 --- a/liveobjects/build.gradle.kts +++ b/liveobjects/build.gradle.kts @@ -30,17 +30,17 @@ tasks.withType().configureEach { outputs.upToDateWhen { false } } -tasks.register("runLiveObjectUnitTests") { +tasks.register("runLiveObjectsUnitTests") { filter { - includeTestsMatching("io.ably.lib.object.unit.*") + includeTestsMatching("io.ably.lib.liveobjects.unit.*") } } -tasks.register("runLiveObjectIntegrationTests") { +tasks.register("runLiveObjectsIntegrationTests") { filter { - includeTestsMatching("io.ably.lib.object.integration.*") + includeTestsMatching("io.ably.lib.liveobjects.integration.*") // Exclude the base integration test class - excludeTestsMatching("io.ably.lib.object.integration.setup.IntegrationTest") + excludeTestsMatching("io.ably.lib.liveobjects.integration.setup.IntegrationTest") } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt similarity index 84% rename from liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObject.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt index 5e5bae61d..ff6dbb070 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt @@ -1,9 +1,9 @@ -package io.ably.lib.`object` +package io.ably.lib.liveobjects -import io.ably.lib.`object`.adapter.AblyClientAdapter -import io.ably.lib.`object`.path.types.LiveMapPathObject -import io.ably.lib.`object`.state.ObjectStateChange -import io.ably.lib.`object`.state.ObjectStateEvent +import io.ably.lib.liveobjects.adapter.AblyClientAdapter +import io.ably.lib.liveobjects.path.types.LiveMapPathObject +import io.ably.lib.liveobjects.state.ObjectStateChange +import io.ably.lib.liveobjects.state.ObjectStateEvent import java.util.concurrent.CompletableFuture /** diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/Errors.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Errors.kt similarity index 98% rename from liveobjects/src/main/kotlin/io/ably/lib/object/Errors.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Errors.kt index 8b2c3fcfd..98bd89691 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/Errors.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Errors.kt @@ -1,4 +1,4 @@ -package io.ably.lib.`object` +package io.ably.lib.liveobjects import io.ably.lib.types.AblyException import io.ably.lib.types.ErrorInfo diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Helpers.kt similarity index 97% rename from liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Helpers.kt index e450c0d49..e8859cc2b 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/Helpers.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Helpers.kt @@ -1,8 +1,8 @@ -package io.ably.lib.`object` +package io.ably.lib.liveobjects -import io.ably.lib.`object`.adapter.AblyClientAdapter -import io.ably.lib.`object`.message.WireObjectMessage -import io.ably.lib.`object`.message.size +import io.ably.lib.liveobjects.adapter.AblyClientAdapter +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.size import io.ably.lib.realtime.ChannelState import io.ably.lib.realtime.CompletionListener import io.ably.lib.realtime.ConnectionEvent diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/Utils.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Utils.kt similarity index 98% rename from liveobjects/src/main/kotlin/io/ably/lib/object/Utils.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Utils.kt index 4509140eb..8cc628a32 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/Utils.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Utils.kt @@ -1,4 +1,4 @@ -package io.ably.lib.`object` +package io.ably.lib.liveobjects import io.ably.lib.types.AblyException import io.ably.lib.types.ErrorInfo diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstance.kt similarity index 73% rename from liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstance.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstance.kt index 151949b1b..0b710bbca 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstance.kt @@ -1,14 +1,14 @@ -package io.ably.lib.`object`.instance - -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.instance.types.BinaryInstance -import io.ably.lib.`object`.instance.types.BooleanInstance -import io.ably.lib.`object`.instance.types.JsonArrayInstance -import io.ably.lib.`object`.instance.types.JsonObjectInstance -import io.ably.lib.`object`.instance.types.LiveCounterInstance -import io.ably.lib.`object`.instance.types.LiveMapInstance -import io.ably.lib.`object`.instance.types.NumberInstance -import io.ably.lib.`object`.instance.types.StringInstance +package io.ably.lib.liveobjects.instance + +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.instance.types.BinaryInstance +import io.ably.lib.liveobjects.instance.types.BooleanInstance +import io.ably.lib.liveobjects.instance.types.JsonArrayInstance +import io.ably.lib.liveobjects.instance.types.JsonObjectInstance +import io.ably.lib.liveobjects.instance.types.LiveCounterInstance +import io.ably.lib.liveobjects.instance.types.LiveMapInstance +import io.ably.lib.liveobjects.instance.types.NumberInstance +import io.ably.lib.liveobjects.instance.types.StringInstance /** * Default implementation of [Instance], the identity-addressed node in the LiveObjects graph. diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstanceSubscriptionEvent.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstanceSubscriptionEvent.kt similarity index 84% rename from liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstanceSubscriptionEvent.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstanceSubscriptionEvent.kt index 292eb5ad2..428a3b88f 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstanceSubscriptionEvent.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstanceSubscriptionEvent.kt @@ -1,6 +1,6 @@ -package io.ably.lib.`object`.instance +package io.ably.lib.liveobjects.instance -import io.ably.lib.`object`.message.ObjectMessage +import io.ably.lib.liveobjects.message.ObjectMessage /** * Default implementation of [InstanceSubscriptionEvent], the event delivered to an diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBinaryInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBinaryInstance.kt similarity index 78% rename from liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBinaryInstance.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBinaryInstance.kt index 26a470a40..0abf41285 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBinaryInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBinaryInstance.kt @@ -1,9 +1,9 @@ -package io.ably.lib.`object`.instance.types +package io.ably.lib.liveobjects.instance.types import com.google.gson.JsonPrimitive -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.instance.DefaultInstance +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance /** * Default implementation of [BinaryInstance], a read-only primitive view that only adds a diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBooleanInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBooleanInstance.kt similarity index 78% rename from liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBooleanInstance.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBooleanInstance.kt index 3221ce1f4..ab5eeae4d 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultBooleanInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBooleanInstance.kt @@ -1,9 +1,9 @@ -package io.ably.lib.`object`.instance.types +package io.ably.lib.liveobjects.instance.types import com.google.gson.JsonPrimitive -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.instance.DefaultInstance +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance /** * Default implementation of [BooleanInstance], a read-only primitive view that only adds a diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonArrayInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonArrayInstance.kt similarity index 79% rename from liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonArrayInstance.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonArrayInstance.kt index 4e3ba7701..ecd755a32 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonArrayInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonArrayInstance.kt @@ -1,9 +1,9 @@ -package io.ably.lib.`object`.instance.types +package io.ably.lib.liveobjects.instance.types import com.google.gson.JsonArray -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.instance.DefaultInstance +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance /** * Default implementation of [JsonArrayInstance], a read-only primitive view that only adds diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonObjectInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonObjectInstance.kt similarity index 79% rename from liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonObjectInstance.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonObjectInstance.kt index 02dc7c15c..3ce012fd6 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultJsonObjectInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonObjectInstance.kt @@ -1,9 +1,9 @@ -package io.ably.lib.`object`.instance.types +package io.ably.lib.liveobjects.instance.types import com.google.gson.JsonObject -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.instance.DefaultInstance +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance /** * Default implementation of [JsonObjectInstance], a read-only primitive view that only adds diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveCounterInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveCounterInstance.kt similarity index 83% rename from liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveCounterInstance.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveCounterInstance.kt index c78db653f..60aae7cc4 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveCounterInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveCounterInstance.kt @@ -1,12 +1,12 @@ -package io.ably.lib.`object`.instance.types +package io.ably.lib.liveobjects.instance.types import com.google.gson.JsonPrimitive -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.Subscription -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.instance.DefaultInstance -import io.ably.lib.`object`.instance.InstanceListener -import io.ably.lib.`object`.onceSubscription +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.Subscription +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance +import io.ably.lib.liveobjects.instance.InstanceListener +import io.ably.lib.liveobjects.onceSubscription import java.util.concurrent.CompletableFuture /** diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveMapInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveMapInstance.kt similarity index 81% rename from liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveMapInstance.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveMapInstance.kt index 7142dc98a..22d9c3f10 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultLiveMapInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveMapInstance.kt @@ -1,14 +1,14 @@ -package io.ably.lib.`object`.instance.types +package io.ably.lib.liveobjects.instance.types import com.google.gson.JsonObject -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.Subscription -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.instance.DefaultInstance -import io.ably.lib.`object`.instance.Instance -import io.ably.lib.`object`.instance.InstanceListener -import io.ably.lib.`object`.onceSubscription -import io.ably.lib.`object`.value.LiveMapValue +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.Subscription +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance +import io.ably.lib.liveobjects.instance.Instance +import io.ably.lib.liveobjects.instance.InstanceListener +import io.ably.lib.liveobjects.onceSubscription +import io.ably.lib.liveobjects.value.LiveMapValue import java.util.concurrent.CompletableFuture /** diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultNumberInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultNumberInstance.kt similarity index 78% rename from liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultNumberInstance.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultNumberInstance.kt index 3e85ddade..27910de7b 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultNumberInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultNumberInstance.kt @@ -1,9 +1,9 @@ -package io.ably.lib.`object`.instance.types +package io.ably.lib.liveobjects.instance.types import com.google.gson.JsonPrimitive -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.instance.DefaultInstance +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance /** * Default implementation of [NumberInstance], a read-only primitive view that only adds a diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultStringInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultStringInstance.kt similarity index 78% rename from liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultStringInstance.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultStringInstance.kt index 74465782c..998608e9b 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/types/DefaultStringInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultStringInstance.kt @@ -1,9 +1,9 @@ -package io.ably.lib.`object`.instance.types +package io.ably.lib.liveobjects.instance.types import com.google.gson.JsonPrimitive -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.instance.DefaultInstance +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance /** * Default implementation of [StringInstance], a read-only primitive view that only adds a diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/message/DefaultObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/DefaultObjectMessage.kt similarity index 98% rename from liveobjects/src/main/kotlin/io/ably/lib/object/message/DefaultObjectMessage.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/DefaultObjectMessage.kt index f75f2ef2c..d206b37fe 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/message/DefaultObjectMessage.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/DefaultObjectMessage.kt @@ -1,8 +1,8 @@ -package io.ably.lib.`object`.message +package io.ably.lib.liveobjects.message import com.google.gson.JsonElement import com.google.gson.JsonObject -import io.ably.lib.`object`.objectStateError +import io.ably.lib.liveobjects.objectStateError import java.util.* /** diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/WireObjectMessage.kt similarity index 97% rename from liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/WireObjectMessage.kt index 28b58d56e..e29b73533 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/WireObjectMessage.kt @@ -1,12 +1,12 @@ -package io.ably.lib.`object`.message +package io.ably.lib.liveobjects.message import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.annotations.JsonAdapter import com.google.gson.annotations.SerializedName -import io.ably.lib.`object`.byteSize -import io.ably.lib.`object`.serialization.WireObjectDataJsonSerializer -import io.ably.lib.`object`.serialization.gson +import io.ably.lib.liveobjects.byteSize +import io.ably.lib.liveobjects.serialization.WireObjectDataJsonSerializer +import io.ably.lib.liveobjects.serialization.gson import java.util.Base64 /** diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObject.kt similarity index 69% rename from liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObject.kt index 69e25298e..4cc3a38c8 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObject.kt @@ -1,29 +1,29 @@ -package io.ably.lib.`object`.path +package io.ably.lib.liveobjects.path import com.google.gson.JsonElement -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.Subscription -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.instance.Instance -import io.ably.lib.`object`.onceSubscription -import io.ably.lib.`object`.path.types.BinaryPathObject -import io.ably.lib.`object`.path.types.BooleanPathObject -import io.ably.lib.`object`.path.types.DefaultBinaryPathObject -import io.ably.lib.`object`.path.types.DefaultBooleanPathObject -import io.ably.lib.`object`.path.types.DefaultJsonArrayPathObject -import io.ably.lib.`object`.path.types.DefaultJsonObjectPathObject -import io.ably.lib.`object`.path.types.DefaultLiveCounterPathObject -import io.ably.lib.`object`.path.types.DefaultLiveMapPathObject -import io.ably.lib.`object`.path.types.DefaultNumberPathObject -import io.ably.lib.`object`.path.types.DefaultStringPathObject -import io.ably.lib.`object`.path.types.JsonArrayPathObject -import io.ably.lib.`object`.path.types.JsonObjectPathObject -import io.ably.lib.`object`.path.types.LiveCounterPathObject -import io.ably.lib.`object`.path.types.LiveMapPathObject -import io.ably.lib.`object`.path.types.NumberPathObject -import io.ably.lib.`object`.path.types.StringPathObject -import io.ably.lib.`object`.value.ResolvedValue -import io.ably.lib.`object`.value.valueType +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.Subscription +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.Instance +import io.ably.lib.liveobjects.onceSubscription +import io.ably.lib.liveobjects.path.types.BinaryPathObject +import io.ably.lib.liveobjects.path.types.BooleanPathObject +import io.ably.lib.liveobjects.path.types.DefaultBinaryPathObject +import io.ably.lib.liveobjects.path.types.DefaultBooleanPathObject +import io.ably.lib.liveobjects.path.types.DefaultJsonArrayPathObject +import io.ably.lib.liveobjects.path.types.DefaultJsonObjectPathObject +import io.ably.lib.liveobjects.path.types.DefaultLiveCounterPathObject +import io.ably.lib.liveobjects.path.types.DefaultLiveMapPathObject +import io.ably.lib.liveobjects.path.types.DefaultNumberPathObject +import io.ably.lib.liveobjects.path.types.DefaultStringPathObject +import io.ably.lib.liveobjects.path.types.JsonArrayPathObject +import io.ably.lib.liveobjects.path.types.JsonObjectPathObject +import io.ably.lib.liveobjects.path.types.LiveCounterPathObject +import io.ably.lib.liveobjects.path.types.LiveMapPathObject +import io.ably.lib.liveobjects.path.types.NumberPathObject +import io.ably.lib.liveobjects.path.types.StringPathObject +import io.ably.lib.liveobjects.value.ResolvedValue +import io.ably.lib.liveobjects.value.valueType /** * Default implementation of [PathObject], the untyped node in the path-addressed view of diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObjectSubscriptionEvent.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObjectSubscriptionEvent.kt similarity index 85% rename from liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObjectSubscriptionEvent.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObjectSubscriptionEvent.kt index 8a73882be..17e474807 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObjectSubscriptionEvent.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObjectSubscriptionEvent.kt @@ -1,6 +1,6 @@ -package io.ably.lib.`object`.path +package io.ably.lib.liveobjects.path -import io.ably.lib.`object`.message.ObjectMessage +import io.ably.lib.liveobjects.message.ObjectMessage /** * Default implementation of [PathObjectSubscriptionEvent], the event delivered to a diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBinaryPathObject.kt similarity index 74% rename from liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBinaryPathObject.kt index d8e3e4980..35ac94c5d 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBinaryPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBinaryPathObject.kt @@ -1,9 +1,9 @@ -package io.ably.lib.`object`.path.types +package io.ably.lib.liveobjects.path.types -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.path.DefaultPathObject -import io.ably.lib.`object`.value.valueType +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.value.valueType /** * Default implementation of [BinaryPathObject], a terminal primitive view that only adds a diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBooleanPathObject.kt similarity index 73% rename from liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBooleanPathObject.kt index 0ffdf3e7e..e8554f780 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultBooleanPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBooleanPathObject.kt @@ -1,9 +1,9 @@ -package io.ably.lib.`object`.path.types +package io.ably.lib.liveobjects.path.types -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.path.DefaultPathObject -import io.ably.lib.`object`.value.valueType +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.value.valueType /** * Default implementation of [BooleanPathObject], a terminal primitive view that only adds a diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonArrayPathObject.kt similarity index 75% rename from liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonArrayPathObject.kt index 6a05091dd..fa40c460f 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonArrayPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonArrayPathObject.kt @@ -1,10 +1,10 @@ -package io.ably.lib.`object`.path.types +package io.ably.lib.liveobjects.path.types import com.google.gson.JsonArray -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.path.DefaultPathObject -import io.ably.lib.`object`.value.valueType +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.value.valueType /** * Default implementation of [JsonArrayPathObject], a terminal primitive view that only adds diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonObjectPathObject.kt similarity index 75% rename from liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonObjectPathObject.kt index 197149718..e9362fcfe 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultJsonObjectPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonObjectPathObject.kt @@ -1,10 +1,10 @@ -package io.ably.lib.`object`.path.types +package io.ably.lib.liveobjects.path.types import com.google.gson.JsonObject -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.path.DefaultPathObject -import io.ably.lib.`object`.value.valueType +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.value.valueType /** * Default implementation of [JsonObjectPathObject], a terminal primitive view that only adds diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveCounterPathObject.kt similarity index 89% rename from liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveCounterPathObject.kt index 7b5bb756c..6e4e320ca 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveCounterPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveCounterPathObject.kt @@ -1,10 +1,10 @@ -package io.ably.lib.`object`.path.types +package io.ably.lib.liveobjects.path.types -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.path.DefaultPathObject -import io.ably.lib.`object`.pathNotResolvedError -import io.ably.lib.`object`.typeMismatchError -import io.ably.lib.`object`.value.ResolvedValue +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.pathNotResolvedError +import io.ably.lib.liveobjects.typeMismatchError +import io.ably.lib.liveobjects.value.ResolvedValue import java.util.concurrent.CompletableFuture /** diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveMapPathObject.kt similarity index 87% rename from liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveMapPathObject.kt index 6e1cd050e..8c6a561d5 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultLiveMapPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveMapPathObject.kt @@ -1,12 +1,12 @@ -package io.ably.lib.`object`.path.types +package io.ably.lib.liveobjects.path.types -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.path.DefaultPathObject -import io.ably.lib.`object`.path.PathObject -import io.ably.lib.`object`.pathNotResolvedError -import io.ably.lib.`object`.typeMismatchError -import io.ably.lib.`object`.value.LiveMapValue -import io.ably.lib.`object`.value.ResolvedValue +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.path.PathObject +import io.ably.lib.liveobjects.pathNotResolvedError +import io.ably.lib.liveobjects.typeMismatchError +import io.ably.lib.liveobjects.value.LiveMapValue +import io.ably.lib.liveobjects.value.ResolvedValue import java.util.concurrent.CompletableFuture /** diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultNumberPathObject.kt similarity index 73% rename from liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultNumberPathObject.kt index 7f1498dab..bc64dd28c 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultNumberPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultNumberPathObject.kt @@ -1,9 +1,9 @@ -package io.ably.lib.`object`.path.types +package io.ably.lib.liveobjects.path.types -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.path.DefaultPathObject -import io.ably.lib.`object`.value.valueType +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.value.valueType /** * Default implementation of [NumberPathObject], a terminal primitive view that only adds a diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultStringPathObject.kt similarity index 73% rename from liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultStringPathObject.kt index af9fa6255..4275c84c4 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/types/DefaultStringPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultStringPathObject.kt @@ -1,9 +1,9 @@ -package io.ably.lib.`object`.path.types +package io.ably.lib.liveobjects.path.types -import io.ably.lib.`object`.DefaultRealtimeObject -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.path.DefaultPathObject -import io.ably.lib.`object`.value.valueType +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.value.valueType /** * Default implementation of [StringPathObject], a terminal primitive view that only adds a diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/DefaultSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/DefaultSerialization.kt similarity index 93% rename from liveobjects/src/main/kotlin/io/ably/lib/object/serialization/DefaultSerialization.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/DefaultSerialization.kt index f410999fd..7deb4fe24 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/DefaultSerialization.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/DefaultSerialization.kt @@ -1,7 +1,7 @@ -package io.ably.lib.`object`.serialization +package io.ably.lib.liveobjects.serialization import com.google.gson.* -import io.ably.lib.`object`.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectMessage import org.msgpack.core.MessagePacker import org.msgpack.core.MessageUnpacker diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/JsonSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/JsonSerialization.kt similarity index 91% rename from liveobjects/src/main/kotlin/io/ably/lib/object/serialization/JsonSerialization.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/JsonSerialization.kt index cc5098cc5..3620dfee9 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/JsonSerialization.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/JsonSerialization.kt @@ -1,10 +1,10 @@ -package io.ably.lib.`object`.serialization +package io.ably.lib.liveobjects.serialization import com.google.gson.* -import io.ably.lib.`object`.message.WireObjectData -import io.ably.lib.`object`.message.WireObjectMessage -import io.ably.lib.`object`.message.WireObjectOperationAction -import io.ably.lib.`object`.message.WireObjectsMapSemantics +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.message.WireObjectsMapSemantics import java.lang.reflect.Type import kotlin.enums.EnumEntries diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/MsgpackSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/MsgpackSerialization.kt similarity index 95% rename from liveobjects/src/main/kotlin/io/ably/lib/object/serialization/MsgpackSerialization.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/MsgpackSerialization.kt index 849f41a4e..18d5c0701 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/serialization/MsgpackSerialization.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/MsgpackSerialization.kt @@ -1,27 +1,27 @@ -package io.ably.lib.`object`.serialization +package io.ably.lib.liveobjects.serialization import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser -import io.ably.lib.`object`.message.WireCounterCreate -import io.ably.lib.`object`.message.WireCounterCreateWithObjectId -import io.ably.lib.`object`.message.WireCounterInc -import io.ably.lib.`object`.message.WireMapClear -import io.ably.lib.`object`.message.WireMapCreate -import io.ably.lib.`object`.message.WireMapCreateWithObjectId -import io.ably.lib.`object`.message.WireMapRemove -import io.ably.lib.`object`.message.WireMapSet -import io.ably.lib.`object`.message.WireObjectData -import io.ably.lib.`object`.message.WireObjectDelete -import io.ably.lib.`object`.message.WireObjectMessage -import io.ably.lib.`object`.message.WireObjectOperation -import io.ably.lib.`object`.message.WireObjectOperationAction -import io.ably.lib.`object`.message.WireObjectState -import io.ably.lib.`object`.message.WireObjectsCounter -import io.ably.lib.`object`.message.WireObjectsMap -import io.ably.lib.`object`.message.WireObjectsMapEntry -import io.ably.lib.`object`.message.WireObjectsMapSemantics -import io.ably.lib.`object`.objectStateError +import io.ably.lib.liveobjects.message.WireCounterCreate +import io.ably.lib.liveobjects.message.WireCounterCreateWithObjectId +import io.ably.lib.liveobjects.message.WireCounterInc +import io.ably.lib.liveobjects.message.WireMapClear +import io.ably.lib.liveobjects.message.WireMapCreate +import io.ably.lib.liveobjects.message.WireMapCreateWithObjectId +import io.ably.lib.liveobjects.message.WireMapRemove +import io.ably.lib.liveobjects.message.WireMapSet +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectDelete +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperation +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.message.WireObjectState +import io.ably.lib.liveobjects.message.WireObjectsCounter +import io.ably.lib.liveobjects.message.WireObjectsMap +import io.ably.lib.liveobjects.message.WireObjectsMapEntry +import io.ably.lib.liveobjects.message.WireObjectsMapSemantics +import io.ably.lib.liveobjects.objectStateError import io.ably.lib.util.Serialisation import java.util.Base64 import org.msgpack.core.MessageFormat diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveCounter.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveCounter.kt similarity index 95% rename from liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveCounter.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveCounter.kt index 43fec3909..bbeeb60ea 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveCounter.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveCounter.kt @@ -1,4 +1,4 @@ -package io.ably.lib.`object`.value +package io.ably.lib.liveobjects.value /** * Default implementation of the [LiveCounter] value type - an immutable holder for diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveMap.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveMap.kt similarity index 96% rename from liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveMap.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveMap.kt index 4f6520b39..b0fcd9abb 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveMap.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveMap.kt @@ -1,4 +1,4 @@ -package io.ably.lib.`object`.value +package io.ably.lib.liveobjects.value /** * Default implementation of the [LiveMap] value type - an immutable holder for the diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/value/ResolvedValue.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/ResolvedValue.kt similarity index 91% rename from liveobjects/src/main/kotlin/io/ably/lib/object/value/ResolvedValue.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/ResolvedValue.kt index e74b44ff9..34002837d 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/value/ResolvedValue.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/ResolvedValue.kt @@ -1,7 +1,7 @@ -package io.ably.lib.`object`.value +package io.ably.lib.liveobjects.value -import io.ably.lib.`object`.ValueType -import io.ably.lib.`object`.message.WireObjectData +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.message.WireObjectData /** * The result of resolving a path segment / map entry against the objects diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/TestUtils.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/TestUtils.kt similarity index 98% rename from liveobjects/src/test/kotlin/io/ably/lib/object/TestUtils.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/TestUtils.kt index f71e21727..65c712463 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/TestUtils.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/TestUtils.kt @@ -1,4 +1,4 @@ -package io.ably.lib.`object` +package io.ably.lib.liveobjects import java.lang.reflect.Field import kotlinx.coroutines.Dispatchers diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/DefaultRealtimeObjectTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/DefaultRealtimeObjectTest.kt similarity index 90% rename from liveobjects/src/test/kotlin/io/ably/lib/object/integration/DefaultRealtimeObjectTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/DefaultRealtimeObjectTest.kt index 1263cb1fb..7fc44f60a 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/DefaultRealtimeObjectTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/DefaultRealtimeObjectTest.kt @@ -1,7 +1,7 @@ -package io.ably.lib.`object`.integration +package io.ably.lib.liveobjects.integration -import io.ably.lib.`object`.assertWaiter -import io.ably.lib.`object`.integration.setup.IntegrationTest +import io.ably.lib.liveobjects.assertWaiter +import io.ably.lib.liveobjects.integration.setup.IntegrationTest import io.ably.lib.realtime.ChannelState import kotlinx.coroutines.test.runTest import org.junit.Test diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/PayloadBuilder.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/PayloadBuilder.kt similarity index 92% rename from liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/PayloadBuilder.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/PayloadBuilder.kt index 6c6d30e48..2f025059c 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/PayloadBuilder.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/PayloadBuilder.kt @@ -1,10 +1,10 @@ -package io.ably.lib.`object`.integration.helpers +package io.ably.lib.liveobjects.integration.helpers import com.google.gson.JsonObject -import io.ably.lib.`object`.generateNonce -import io.ably.lib.`object`.message.WireObjectData -import io.ably.lib.`object`.message.WireObjectOperationAction -import io.ably.lib.`object`.serialization.gson +import io.ably.lib.liveobjects.generateNonce +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.serialization.gson internal object PayloadBuilder { /** diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/RestObjects.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/RestObjects.kt similarity index 96% rename from liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/RestObjects.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/RestObjects.kt index 32d76dac0..62ef8d7d5 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/RestObjects.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/RestObjects.kt @@ -1,10 +1,10 @@ -package io.ably.lib.`object`.integration.helpers +package io.ably.lib.liveobjects.integration.helpers import com.google.gson.JsonObject -import io.ably.lib.`object`.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectData import io.ably.lib.rest.AblyRest import io.ably.lib.http.HttpUtils -import io.ably.lib.`object`.integration.helpers.fixtures.DataFixtures +import io.ably.lib.liveobjects.integration.helpers.fixtures.DataFixtures import io.ably.lib.types.ClientOptions /** diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/CounterFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/CounterFixtures.kt similarity index 96% rename from liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/CounterFixtures.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/CounterFixtures.kt index 316cab07e..86f454d41 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/CounterFixtures.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/CounterFixtures.kt @@ -1,6 +1,6 @@ -package io.ably.lib.`object`.integration.helpers.fixtures +package io.ably.lib.liveobjects.integration.helpers.fixtures -import io.ably.lib.`object`.integration.helpers.RestObjects +import io.ably.lib.liveobjects.integration.helpers.RestObjects /** * Creates a comprehensive test fixture object tree focused on user-context counters. diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/DataFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/DataFixtures.kt similarity index 96% rename from liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/DataFixtures.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/DataFixtures.kt index 4b9783635..57227dc7e 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/DataFixtures.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/DataFixtures.kt @@ -1,8 +1,8 @@ -package io.ably.lib.`object`.integration.helpers.fixtures +package io.ably.lib.liveobjects.integration.helpers.fixtures import com.google.gson.JsonArray import com.google.gson.JsonObject -import io.ably.lib.`object`.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectData import java.util.Base64 internal object DataFixtures { diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/MapFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/MapFixtures.kt similarity index 97% rename from liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/MapFixtures.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/MapFixtures.kt index 79de1e288..2d92233e2 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/helpers/fixtures/MapFixtures.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/MapFixtures.kt @@ -1,7 +1,7 @@ -package io.ably.lib.`object`.integration.helpers.fixtures +package io.ably.lib.liveobjects.integration.helpers.fixtures -import io.ably.lib.`object`.message.WireObjectData -import io.ably.lib.`object`.integration.helpers.RestObjects +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.integration.helpers.RestObjects /** * Initializes a comprehensive test fixture object tree on the specified channel. diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/setup/IntegrationTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/IntegrationTest.kt similarity index 96% rename from liveobjects/src/test/kotlin/io/ably/lib/object/integration/setup/IntegrationTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/IntegrationTest.kt index 80ab9347d..1f0a3dba1 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/setup/IntegrationTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/IntegrationTest.kt @@ -1,6 +1,6 @@ -package io.ably.lib.`object`.integration.setup +package io.ably.lib.liveobjects.integration.setup -import io.ably.lib.`object`.integration.helpers.RestObjects +import io.ably.lib.liveobjects.integration.helpers.RestObjects import io.ably.lib.realtime.AblyRealtime import io.ably.lib.realtime.Channel import io.ably.lib.types.ChannelMode diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/setup/Sandbox.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/Sandbox.kt similarity index 94% rename from liveobjects/src/test/kotlin/io/ably/lib/object/integration/setup/Sandbox.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/Sandbox.kt index 05a7e3ce8..5cc2f9360 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/integration/setup/Sandbox.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/Sandbox.kt @@ -1,9 +1,9 @@ -package io.ably.lib.`object`.integration.setup +package io.ably.lib.liveobjects.integration.setup import com.google.gson.JsonElement import com.google.gson.JsonParser -import io.ably.lib.`object`.ablyException -import io.ably.lib.`object`.integration.helpers.RestObjects +import io.ably.lib.liveobjects.ablyException +import io.ably.lib.liveobjects.integration.helpers.RestObjects import io.ably.lib.realtime.* import io.ably.lib.types.ClientOptions import io.ktor.client.* diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/HelpersTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/HelpersTest.kt similarity index 98% rename from liveobjects/src/test/kotlin/io/ably/lib/object/unit/HelpersTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/HelpersTest.kt index a8360d758..76c37d21d 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/HelpersTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/HelpersTest.kt @@ -1,10 +1,10 @@ -package io.ably.lib.`object`.unit +package io.ably.lib.liveobjects.unit -import io.ably.lib.`object`.* -import io.ably.lib.`object`.adapter.AblyClientAdapter -import io.ably.lib.`object`.clientError -import io.ably.lib.`object`.connectionManager -import io.ably.lib.`object`.sendAsync +import io.ably.lib.liveobjects.* +import io.ably.lib.liveobjects.adapter.AblyClientAdapter +import io.ably.lib.liveobjects.clientError +import io.ably.lib.liveobjects.connectionManager +import io.ably.lib.liveobjects.sendAsync import io.ably.lib.realtime.Channel import io.ably.lib.realtime.ChannelState import io.ably.lib.realtime.ChannelStateListener diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/ObjectMessageSerializationTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/ObjectMessageSerializationTest.kt similarity index 92% rename from liveobjects/src/test/kotlin/io/ably/lib/object/unit/ObjectMessageSerializationTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/ObjectMessageSerializationTest.kt index f3e30b520..3a94be91d 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/ObjectMessageSerializationTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/ObjectMessageSerializationTest.kt @@ -1,16 +1,16 @@ -package io.ably.lib.`object`.unit +package io.ably.lib.liveobjects.unit import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.JsonElement import com.google.gson.JsonNull -import io.ably.lib.`object`.unit.fixtures.dummyObjectMessageWithBinaryData -import io.ably.lib.`object`.unit.fixtures.dummyObjectMessageWithBooleanData -import io.ably.lib.`object`.unit.fixtures.dummyObjectMessageWithJsonArrayData -import io.ably.lib.`object`.unit.fixtures.dummyObjectMessageWithJsonObjectData -import io.ably.lib.`object`.unit.fixtures.dummyObjectMessageWithNumberData -import io.ably.lib.`object`.unit.fixtures.dummyObjectMessageWithStringData -import io.ably.lib.`object`.message.WireObjectMessage +import io.ably.lib.liveobjects.unit.fixtures.dummyObjectMessageWithBinaryData +import io.ably.lib.liveobjects.unit.fixtures.dummyObjectMessageWithBooleanData +import io.ably.lib.liveobjects.unit.fixtures.dummyObjectMessageWithJsonArrayData +import io.ably.lib.liveobjects.unit.fixtures.dummyObjectMessageWithJsonObjectData +import io.ably.lib.liveobjects.unit.fixtures.dummyObjectMessageWithNumberData +import io.ably.lib.liveobjects.unit.fixtures.dummyObjectMessageWithStringData +import io.ably.lib.liveobjects.message.WireObjectMessage import io.ably.lib.types.ProtocolMessage import io.ably.lib.types.ProtocolMessage.ActionSerializer import io.ably.lib.types.ProtocolSerializer diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/ObjectMessageSizeTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/ObjectMessageSizeTest.kt similarity index 87% rename from liveobjects/src/test/kotlin/io/ably/lib/object/unit/ObjectMessageSizeTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/ObjectMessageSizeTest.kt index ba6ca11c9..8449a603c 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/ObjectMessageSizeTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/ObjectMessageSizeTest.kt @@ -1,24 +1,24 @@ -package io.ably.lib.`object`.unit +package io.ably.lib.liveobjects.unit import com.google.gson.JsonObject -import io.ably.lib.`object`.connectionManager -import io.ably.lib.`object`.ensureMessageSizeWithinLimit -import io.ably.lib.`object`.message.WireCounterCreate -import io.ably.lib.`object`.message.WireCounterCreateWithObjectId -import io.ably.lib.`object`.message.WireCounterInc -import io.ably.lib.`object`.message.WireMapCreate -import io.ably.lib.`object`.message.WireMapCreateWithObjectId -import io.ably.lib.`object`.message.WireMapSet -import io.ably.lib.`object`.message.WireObjectData -import io.ably.lib.`object`.message.WireObjectMessage -import io.ably.lib.`object`.message.WireObjectOperation -import io.ably.lib.`object`.message.WireObjectOperationAction -import io.ably.lib.`object`.message.WireObjectState -import io.ably.lib.`object`.message.WireObjectsCounter -import io.ably.lib.`object`.message.WireObjectsMap -import io.ably.lib.`object`.message.WireObjectsMapEntry -import io.ably.lib.`object`.message.WireObjectsMapSemantics -import io.ably.lib.`object`.message.size +import io.ably.lib.liveobjects.connectionManager +import io.ably.lib.liveobjects.ensureMessageSizeWithinLimit +import io.ably.lib.liveobjects.message.WireCounterCreate +import io.ably.lib.liveobjects.message.WireCounterCreateWithObjectId +import io.ably.lib.liveobjects.message.WireCounterInc +import io.ably.lib.liveobjects.message.WireMapCreate +import io.ably.lib.liveobjects.message.WireMapCreateWithObjectId +import io.ably.lib.liveobjects.message.WireMapSet +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperation +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.message.WireObjectState +import io.ably.lib.liveobjects.message.WireObjectsCounter +import io.ably.lib.liveobjects.message.WireObjectsMap +import io.ably.lib.liveobjects.message.WireObjectsMapEntry +import io.ably.lib.liveobjects.message.WireObjectsMapSemantics +import io.ably.lib.liveobjects.message.size import io.ably.lib.transport.Defaults import io.ably.lib.types.AblyException import kotlinx.coroutines.test.runTest diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/TestHelpers.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/TestHelpers.kt similarity index 89% rename from liveobjects/src/test/kotlin/io/ably/lib/object/unit/TestHelpers.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/TestHelpers.kt index 3a12eef14..1ccfbc66e 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/TestHelpers.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/TestHelpers.kt @@ -1,7 +1,7 @@ -package io.ably.lib.`object`.unit +package io.ably.lib.liveobjects.unit -import io.ably.lib.`object`.adapter.AblyClientAdapter -import io.ably.lib.`object`.connectionManager +import io.ably.lib.liveobjects.adapter.AblyClientAdapter +import io.ably.lib.liveobjects.connectionManager import io.ably.lib.realtime.AblyRealtime import io.ably.lib.realtime.Channel import io.ably.lib.realtime.ChannelState @@ -41,7 +41,7 @@ internal fun getMockRealtimeChannel( } internal fun getMockAblyClientAdapter(): AblyClientAdapter { - mockkStatic("io.ably.lib.object.HelpersKt") + mockkStatic("io.ably.lib.liveobjects.HelpersKt") return mockk(relaxed = true) { every { getChannel(any()) } returns getMockRealtimeChannel("testChannelName") every { connectionManager } returns mockk(relaxed = true) diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/UtilsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/UtilsTest.kt similarity index 91% rename from liveobjects/src/test/kotlin/io/ably/lib/object/unit/UtilsTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/UtilsTest.kt index 8a64d0987..169e72d9c 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/UtilsTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/UtilsTest.kt @@ -1,11 +1,11 @@ -package io.ably.lib.`object`.unit +package io.ably.lib.liveobjects.unit -import io.ably.lib.`object`.* -import io.ably.lib.`object`.ObjectErrorCode -import io.ably.lib.`object`.ObjectHttpStatusCode -import io.ably.lib.`object`.byteSize -import io.ably.lib.`object`.clientError -import io.ably.lib.`object`.generateNonce +import io.ably.lib.liveobjects.* +import io.ably.lib.liveobjects.ObjectErrorCode +import io.ably.lib.liveobjects.ObjectHttpStatusCode +import io.ably.lib.liveobjects.byteSize +import io.ably.lib.liveobjects.clientError +import io.ably.lib.liveobjects.generateNonce import io.ably.lib.types.ErrorInfo import org.junit.Test import org.junit.Assert.* diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/fixtures/ObjectMessageFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/fixtures/ObjectMessageFixtures.kt similarity index 88% rename from liveobjects/src/test/kotlin/io/ably/lib/object/unit/fixtures/ObjectMessageFixtures.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/fixtures/ObjectMessageFixtures.kt index f31833b2f..5fcccf56d 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/fixtures/ObjectMessageFixtures.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/fixtures/ObjectMessageFixtures.kt @@ -1,19 +1,19 @@ -package io.ably.lib.`object`.unit.fixtures +package io.ably.lib.liveobjects.unit.fixtures import com.google.gson.JsonArray import com.google.gson.JsonObject -import io.ably.lib.`object`.message.WireMapCreate -import io.ably.lib.`object`.message.WireMapCreateWithObjectId -import io.ably.lib.`object`.message.WireMapSet -import io.ably.lib.`object`.message.WireObjectData -import io.ably.lib.`object`.message.WireObjectMessage -import io.ably.lib.`object`.message.WireObjectOperation -import io.ably.lib.`object`.message.WireObjectOperationAction -import io.ably.lib.`object`.message.WireObjectState -import io.ably.lib.`object`.message.WireObjectsCounter -import io.ably.lib.`object`.message.WireObjectsMap -import io.ably.lib.`object`.message.WireObjectsMapEntry -import io.ably.lib.`object`.message.WireObjectsMapSemantics +import io.ably.lib.liveobjects.message.WireMapCreate +import io.ably.lib.liveobjects.message.WireMapCreateWithObjectId +import io.ably.lib.liveobjects.message.WireMapSet +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperation +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.message.WireObjectState +import io.ably.lib.liveobjects.message.WireObjectsCounter +import io.ably.lib.liveobjects.message.WireObjectsMap +import io.ably.lib.liveobjects.message.WireObjectsMapEntry +import io.ably.lib.liveobjects.message.WireObjectsMapSemantics import java.util.Base64 internal val dummyObjectDataStringValue = WireObjectData(objectId = "object-id", string = "dummy string") From f45a93faf569484504b8e76187efa341238139df Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 24 Jun 2026 18:01:08 +0530 Subject: [PATCH 28/40] Fixed android emulate workflow by fixing correct import --- android/src/main/java/io/ably/lib/realtime/Channel.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/io/ably/lib/realtime/Channel.java b/android/src/main/java/io/ably/lib/realtime/Channel.java index baf086cbc..c0677ba69 100644 --- a/android/src/main/java/io/ably/lib/realtime/Channel.java +++ b/android/src/main/java/io/ably/lib/realtime/Channel.java @@ -3,7 +3,7 @@ import io.ably.lib.types.AblyException; import io.ably.lib.types.ChannelOptions; import io.ably.lib.push.PushChannel; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.liveobjects.LiveObjectsPlugin; public class Channel extends ChannelBase { From 6ec5d809732779707b8c78dc9b2d12a142e9fd24 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 25 Jun 2026 14:39:46 +0530 Subject: [PATCH 29/40] Generated UTS doc to better understand the existing UTS infra. --- UTS_HUMAN_READABLE_DOC.md | 845 ++++++++++++++++++++++++++++++++++++++ website.html | 804 ++++++++++++++++++++++++++++++++++++ 2 files changed, 1649 insertions(+) create mode 100644 UTS_HUMAN_READABLE_DOC.md create mode 100644 website.html diff --git a/UTS_HUMAN_READABLE_DOC.md b/UTS_HUMAN_READABLE_DOC.md new file mode 100644 index 000000000..a322d55a2 --- /dev/null +++ b/UTS_HUMAN_READABLE_DOC.md @@ -0,0 +1,845 @@ +# UTS in ably-java — A Human-Readable Guide + +> A practical, end-to-end explanation of the **Universal Test Specification (UTS)** and how it is +> realised in the `ably-java` repository. Written for a developer who has never touched UTS before +> and needs to understand *what it is*, *why it exists*, and *exactly how the Java/Kotlin code under +> `uts/` makes the unit and proxy-integration tests work*. + +--- + +## Table of Contents + +1. [Introduction: What is UTS?](#1-introduction-what-is-uts) +2. [The Three Test Tiers](#2-the-three-test-tiers) +3. [The UTS Documents (the source of truth)](#3-the-uts-documents-the-source-of-truth) +4. [The Java Setup: the `uts/` module](#4-the-java-setup-the-uts-module) +5. [How a Test Reaches the SDK: the hook points](#5-how-a-test-reaches-the-sdk-the-hook-points) +6. [Unit-Test Infrastructure (mocked transports)](#6-unit-test-infrastructure-mocked-transports) +7. [Proxy-Integration Infrastructure (real backend + fault injection)](#7-proxy-integration-infrastructure-real-backend--fault-injection) +8. [Shared Async Helpers](#8-shared-async-helpers) +9. [Walkthrough: the Unit Test (`ConnectionRecoveryTest`)](#9-walkthrough-the-unit-test-connectionrecoverytest) +10. [Walkthrough: the Proxy Test (`AuthReauthTest`)](#10-walkthrough-the-proxy-test-authreauthtest) +11. [Deviations: when the SDK disagrees with the spec](#11-deviations-when-the-sdk-disagrees-with-the-spec) +12. [How to Run the Tests](#12-how-to-run-the-tests) +13. [Quick Reference / Cheat-Sheet](#13-quick-reference--cheat-sheet) +14. [Appendix A: Request-Flow Diagrams](#14-appendix-a-request-flow-diagrams) +15. [Appendix B: Per-File API Reference](#15-appendix-b-per-file-api-reference) + +--- + +## 1. Introduction: What is UTS? + +**UTS (Universal Test Specification)** is Ably's language-neutral catalogue of tests for its client +SDKs. The problem it solves: Ably ships many SDKs (JavaScript, Dart, Kotlin/Java, Swift, Go, …), and +every one of them must obey the *same* behavioural contract — the **Ably features spec** +(`specification/specifications/features.md`, whose requirements are tagged `RSC7`, `RTN15a`, `RTL4f`, +etc.). Without a shared test definition, each SDK would re-invent its own tests, drift apart, and +leave gaps. + +UTS fixes this by separating **what to test** from **how to test it in a given language**: + +``` + ┌──────────────────────────────┐ + │ Ably features spec │ ← the ultimate authority (RSC*, RTN*, RTL* …) + │ (features.md) │ + └──────────────┬───────────────┘ + │ distilled into portable test specs + ▼ + ┌──────────────────────────────┐ + │ UTS test specs (.md) │ ← language-neutral pseudocode, one file per feature + │ "writing-test-specs" │ e.g. realtime/unit/connection/connection_recovery_test.md + └──────────────┬───────────────┘ + │ translated ("derived") per SDK + ▼ + ┌──────────────────────────────┐ + │ Derived tests │ ← concrete, runnable tests in the SDK's language + │ (this repo: Kotlin in uts/) │ e.g. ConnectionRecoveryTest.kt + └──────────────────────────────┘ +``` + +Three concepts you will see constantly: + +| Term | Meaning | +|------|---------| +| **Spec point** | A tagged requirement in the features spec, e.g. `RTN16g`, `RTN22`, `RTL4f`. Test names embed these. | +| **UTS spec** | A markdown file of portable pseudocode describing the setup, steps, and assertions for one feature. The *source of truth for what to test.* | +| **Derived test** | A faithful translation of a UTS spec into a real test in a specific SDK/language. This is what lives in `ably-java/uts/`. | +| **Deviation** | A documented case where the SDK's actual behaviour diverges from the spec. Recorded in `deviations.md`. | + +The golden rule (from `writing-derived-tests.md`): **translate the UTS spec faithfully** — same +structure, same assertions, same naming — don't optimise or skip steps. Every derived test carries a +`// UTS: ` (here `@UTS …`) comment linking it back to its spec. + +--- + +## 2. The Three Test Tiers + +UTS divides tests into three tiers by *what infrastructure they need* and *what confidence they +give*. Understanding this split is the key to understanding the whole `uts/` module, because the two +tests you asked about sit in two different tiers. + +| Tier | Transport | Backend | Purpose | Example in this repo | +|------|-----------|---------|---------|----------------------| +| **Unit** | **Mocked** (`MockWebSocket`, `MockHttpClient`) | none | Client-side logic: state machines, request formation, response parsing, timer behaviour. Fast & deterministic. | `unit/connection/ConnectionRecoveryTest.kt` | +| **Direct sandbox integration** | Real network | Real Ably sandbox | Happy-path interop: connect, publish, subscribe. No fault injection. | *(not in the two you asked about)* | +| **Proxy integration** | Real network **through a programmable proxy** | Real Ably sandbox | Fault behaviour: dropped connections, injected errors, timeouts, re-auth. | `integration/proxy/AuthReauthTest.kt` | + +Key principles (from `integration-testing.md`): + +- **Integration tests do not replace unit tests.** A spec point covered by a proxy test should + *also* have a unit test. The unit test proves the client logic; the proxy test proves the client + and the real server agree. +- **Proxy tests prefer "late fault injection".** Let the real handshake complete against the real + server, *then* inject the fault as the final interaction. This maximises how much of the test + exercises genuine client-server behaviour (otherwise you've just written a slow unit test). +- **Proxy tests always use JSON** (`useBinaryProtocol = false`). The spec corpus gives two reasons: + the proxy only supports **text** WebSocket frames so it can't inspect/modify msgpack + (`integration-testing.md` §Protocol Variants), and the SDK under test doesn't implement msgpack + (`helpers/proxy.md`). + +--- + +## 3. The UTS Documents (the source of truth) + +These four documents live in the **specification repo** at +`/Users/sachinsh/ably-specification/specification/uts/docs/`. They are the policy/authoring guides; +the Kotlin code in this repo is the *implementation* of what they describe. + +### 3.1 `writing-test-specs.md` — how to author a portable UTS spec +The authoring manual. Defines: +- **Test types** (unit / integration / proxy) and when each applies. +- **Test IDs** — the format `//-`, e.g. + `realtime/proxy/RTN22/server-initiated-reauth-0`. These IDs are what appear in the `@UTS` + comments in the Kotlin tests. +- **Mock infrastructure pseudocode interfaces** — `MockHttpClient`, `MockWebSocket`, + `PendingConnection`, `PendingRequest`, with `respond_with_success()`, `send_to_client()`, + `simulate_disconnect()`, etc. The Kotlin classes in `uts/infra/` are direct realisations of these + interfaces. +- **Handler vs await patterns** for mocks (see §6). +- **WebSocket closing semantics** — the crucial rule: `send_to_client_and_close()` for + DISCONNECTED / connection-level ERROR (server closes the socket); `send_to_client()` for a + channel-level ERROR (connection stays open). +- **Anti-flake conventions** — no fixed `WAIT`s; use polling, `AWAIT_STATE`, fake timers, and the + **record-and-verify** pattern (`CONTAINS_IN_ORDER`) for transient states. + +### 3.2 `writing-derived-tests.md` — how to translate a spec into a real SDK test +The translation manual. Two phases: +1. **Translation** (always): faithfully render the spec into the target language; map pseudocode to + the SDK's API and test framework; flag ambiguities in comments; make sure it compiles. +2. **Evaluation** (when an implementation exists): run the test and, if it fails, work the + **decision tree**: + - *Is the UTS spec wrong* (contradicts features spec)? → fix the test, record a **UTS spec error**. + - *Is the translation wrong*? → fix the test, no deviation. + - *Is the SDK non-compliant*? → keep the spec-correct assertion but adapt/gate it, and record a + **deviation**. +- Defines the **env-gated skip** pattern (`RUN_DEVIATIONS`) — the test holds the *spec-correct* + assertion but only runs it when the env var is set, so normal runs stay green while each deviation + stays individually reproducible. This is exactly what `ConnectionRecoveryTest` uses for RTN16f. + +### 3.3 `integration-testing.md` — the policy for integration & proxy tests +Defines what *deserves* an integration test (request/response interop, error interop, data +round-trips, stateful protocol sequences), the directory layout, sandbox provisioning, proxy session +lifecycle, timeout strategy, and the **late-fault-injection** philosophy. The `integration/proxy/` +segregation exists because proxy tests have different infra needs, CI cadence, and failure modes. + +### 3.4 `completion-status.md` — the coverage matrix +A big table mapping every features-spec group (`RSC`, `RTN`, `RTL`, `RTP`, …) to the UTS specs that +cover it, with a per-tier summary (`unit:✓ proxy:✓`). This is the tracker for "what's done and +what's missing". The two tests you asked about correspond to these rows: +- `RTN16` (connection recovery) → unit spec `connection_recovery_test.md` → **`ConnectionRecoveryTest.kt`**. +- `RTN22` / `RTC8a` (server-initiated re-auth) → proxy spec + `realtime/integration/proxy/auth_reauth.md` → **`AuthReauthTest.kt`**. + +> There is also a fifth, *referenced* spec: `realtime/integration/helpers/proxy.md` (in the spec repo +> under `uts/realtime/integration/helpers/`). It defines the proxy's control API, rule format, +> action types, and the **protocol message action-number table** (CONNECTED=4, ATTACH=10, AUTH=17, +> …). The Kotlin `ProxySession` is the client for exactly that API. + +--- + +## 4. The Java Setup: the `uts/` module + +The `uts/` directory is a **standalone Gradle module** (`include("uts")` in +`settings.gradle.kts`) whose only job is to host UTS-derived tests. It contains *no production code* — +everything lives under `src/test/`. + +### 4.1 `uts/build.gradle.kts` +```kotlin +plugins { alias(libs.plugins.kotlin.jvm) } + +dependencies { + testImplementation(project(":java")) // the SDK under test + testImplementation(project(":network-client-core")) // HttpEngine / WebSocketEngine interfaces + testImplementation(kotlin("test")) + testImplementation(libs.mockk) + testImplementation(libs.coroutine.core) // kotlinx.coroutines + testImplementation(libs.coroutine.test) // runTest, virtual time + testImplementation(libs.ktor.client.core) // HTTP client for proxy/sandbox control + testImplementation(libs.ktor.client.cio) +} + +tasks.withType().configureEach { + useJUnitPlatform() // JUnit 5 + jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED") + jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") + // Propagate a local proxy build override (see ProxyManager): + systemProperty("uts.proxy.localPath", /* -Duts.proxy.localPath=… or $UTS_PROXY_LOCAL_PATH */ …) +} +``` +Takeaways: +- Tests are **Kotlin + JUnit 5**, using **kotlinx.coroutines** for async control and **Ktor** as the + HTTP client that talks to the sandbox REST API and the proxy control API. +- It depends on `:java` (the SDK) and `:network-client-core` (the pluggable transport interfaces the + mocks implement). +- The `--add-opens java.base/java.time` and `java.base/java.lang` flags grant reflective access into + those JDK packages for the test runtime. They mirror the same flags set in `java/build.gradle.kts` + for the SDK's own test module (which additionally opens `java.net` and `java.lang.reflect`). +- A system property carries an optional path to a **locally built** proxy binary (so you can test + against an unreleased proxy). + +### 4.2 Directory layout +``` +uts/src/test/kotlin/io/ably/lib/ +├── Utils.kt # awaitState / awaitChannelState / pollUntil (coroutine helpers) +├── types/Utils.kt # ConnectionDetails { … } builder DSL +├── deviations.md # the catalogue of SDK-vs-spec divergences +│ +├── uts/infra/ # ── UNIT-TEST INFRASTRUCTURE (mocked transports) ── +│ ├── ClientFactories.kt # TestRealtimeClient / TestRestClient / ClientOptionsBuilder +│ ├── MockWebSocket.kt # fake WS transport + WebSocketMockConfig + CONNECTED_MESSAGE +│ ├── MockWebSocketEngineFactory.kt# plugs the mock into the SDK's WebSocketEngine SPI +│ ├── MockHttpClient.kt # fake HTTP engine + HttpMockConfig +│ ├── MockHttpEngine.kt # plugs the mock into the SDK's HttpEngine SPI +│ ├── MockEvent.kt # sealed log of everything that happened on a mock transport +│ ├── PendingConnection.kt # interface: a connection attempt awaiting a response +│ ├── DefaultPendingConnection.kt # WS implementation of PendingConnection +│ ├── PendingRequest.kt # interface: an in-flight HTTP request awaiting a response +│ ├── DefaultPendingRequest.kt # HTTP implementation of PendingRequest +│ └── FakeClock.kt # virtual clock + virtual timers (deterministic time) +│ +├── test/helper/ # ── PROXY-INTEGRATION INFRASTRUCTURE (real backend) ── +│ ├── ProxyManager.kt # downloads/launches the uts-proxy binary +│ ├── ProxySession.kt # one proxy session: rules, actions, event log + connectThroughProxy +│ └── SandboxApp.kt # provisions/deletes a sandbox app +│ +└── realtime/ + ├── unit/connection/ + │ └── ConnectionRecoveryTest.kt # ← the UNIT test (RTN16*) + └── integration/proxy/ + └── AuthReauthTest.kt # ← the PROXY test (RTN22, RTC8a) +``` + +The mental model: **`uts/infra/` powers unit tests, `test/helper/` powers proxy tests, and `Utils.kt` +serves both.** + +--- + +## 5. How a Test Reaches the SDK: the hook points + +A test can only mock transports because the SDK was designed with **pluggable seams**. They live on +`io.ably.lib.debug.DebugOptions` (a subclass of `ClientOptions`): + +```java +public class DebugOptions extends ClientOptions { + public HttpEngine httpEngine; // ← MockHttpClient installs here + public WebSocketEngineFactory webSocketEngineFactory; // ← MockWebSocket installs here + public Clock clock; // ← FakeClock installs here + … +} +``` + +and the `Clock` interface: + +```java +public interface Clock { + long currentTimeMillis(); + long nanoTime(); + AblyTimer newTimer(String name); // every SDK timer is created through this + void waitOn(Object target, long timeout) throws InterruptedException; // every blocking wait +} +``` + +So the recipe is: +- Want to fake the **WebSocket**? Set `webSocketEngineFactory` to a factory that produces a mock + engine. +- Want to fake **HTTP**? Set `httpEngine` to a mock engine. +- Want to control **time** (timeouts, retries, TTL expiry) deterministically? Set `clock` to a + `FakeClock`. + +The `ClientOptionsBuilder` (next section) wraps all three so tests never touch `DebugOptions` +directly. + +--- + +## 6. Unit-Test Infrastructure (mocked transports) + +### 6.1 The client builder — `ClientFactories.kt` +Every unit test builds its client through a tiny DSL: + +```kotlin +class ClientOptionsBuilder : DebugOptions("appId.keyId:keySecret") { + init { useBinaryProtocol = false } // JSON so mocks can decode frames + fun install(mock: MockWebSocket) = mock.installOn(this) + fun install(mock: MockHttpClient) = mock.installOn(this) + fun enableFakeTimers(fakeClock: FakeClock) { clock = fakeClock } +} + +fun TestRealtimeClient(block: ClientOptionsBuilder.() -> Unit): AblyRealtime = + AblyRealtime(ClientOptionsBuilder().apply(block)) +fun TestRestClient(block: ClientOptionsBuilder.() -> Unit): AblyRest = + AblyRest(ClientOptionsBuilder().apply(block)) +``` + +- It seeds a **dummy API key** (`appId.keyId:keySecret`) — fine, because unit tests never hit a real + server and tokens are opaque. +- It forces **JSON** so the mock can parse protocol frames. +- `install(mock)` / `enableFakeTimers(clock)` wire the seams from §5. + +A typical unit test reads: +```kotlin +val mock = MockWebSocket { onConnectionAttempt = { it.respondWithSuccess(CONNECTED_MESSAGE) } } +val client = TestRealtimeClient { + autoConnect = false + install(mock) +} +``` + +### 6.2 `MockWebSocket` — the fake realtime transport +This is the heart of realtime unit testing. It plugs into the SDK via +`MockWebSocketEngineFactory` (which implements the SDK's `WebSocketEngineFactory` SPI from +`network-client-core`), and exposes two complementary control styles: + +**(a) Callback style** — handle inline, synchronously on the SDK thread. Set fields on +`WebSocketMockConfig`: +```kotlin +val mock = MockWebSocket { + onConnectionAttempt = { conn -> conn.respondWithSuccess(CONNECTED_MESSAGE) } + onMessageFromClient = { msg -> /* inspect frames the SDK sent */ } +} +``` +Best when every connection attempt should behave the same way. + +**(b) Await style** — suspend until the SDK triggers something, then respond. Leave the callbacks +null and call the `await*` methods: +```kotlin +val pending = mock.awaitConnectionAttempt() // suspend until SDK opens a socket +pending.respondWithRefused() // …then decide how to answer +val frame = mock.awaitNextMessageFromClient() // suspend until SDK sends a frame +``` +Required when the *first* connection and a *reconnection* need different answers (e.g. +"connect succeeds, then all retries are refused" — exactly the SUSPENDED scenario in the unit test). + +> ⚠️ You cannot mix the two styles for the same event type — a callback consumes the event before the +> queue ever sees it. + +**Server → client direction** (driving the SDK), matching the spec's closing semantics: + +| Method | What it does | Use for | +|--------|--------------|---------| +| `sendToClient(msg)` | deliver a frame, connection stays open | CONNECTED, ATTACHED, channel-level ERROR, normal messages | +| `sendToClientAndClose(msg)` | deliver a frame then close (code 1000) | DISCONNECTED, connection-level ERROR (fatal) | +| `simulateDisconnect()` | close with code 1006, no message | unexpected network drop → triggers DISCONNECTED/resume | + +**Everything is logged.** `mock.events` is an ordered `List` (a sealed class in +`MockEvent.kt`: `ConnectionAttempt`, `ConnectionEstablished`, `ConnectionRefused`, `SentToClient`, +`MessageFromClient`, `ClientClose`, `Disconnected`, …). Tests assert against it, e.g. +`mock.events.filterIsInstance().size`. + +**`CONNECTED_MESSAGE`** is a ready-to-use CONNECTED `ProtocolMessage` (connectionId +`test-connection-id`, a connection key, TTL 120 s, max-idle 15 s) so most tests don't hand-build it. +(It is a `val` with a custom getter, so each access returns a **fresh** instance — not a shared +singleton; safe to mutate per test, e.g. `CONNECTED_MESSAGE.apply { … }`.) + +One subtlety encoded in `DefaultPendingConnection.respondWithSuccess(message)`: the CONNECTED frame is +delivered **asynchronously** on a separate `mock-ws-delivery` thread. That mirrors reality — the SDK +must store the WebSocket reference *before* it processes CONNECTED, so the mock must not deliver it +synchronously inside the connect call. + +### 6.3 `MockHttpClient` — the fake REST transport +The HTTP analogue, plugged in via `MockHttpEngine` (implements the SDK's `HttpEngine` SPI). Same two +styles (`onConnectionAttempt`/`onRequest` callbacks, or `awaitConnectionAttempt()`/`awaitRequest()`). +A request flows in two phases inside `MockHttpCall.execute()`: +1. **Connect phase** → produces a `PendingConnection` (`respondWithSuccess/Refused/Timeout/DnsError`). +2. **Request phase** → produces a `PendingRequest` exposing `url`, `method`, `headers`, `body`, and + `respondWith(status, body, headers)` / `respondWithDelay(...)` / `respondWithTimeout()`. + +This lets REST unit tests assert on outgoing request shape (path, headers, query) and feed canned +responses back — all without a socket. + +### 6.4 `FakeClock` — deterministic time +`FakeClock` implements the SDK's `Clock`. Time is frozen until you call `advance(ms)`; on each +advance it fires any due virtual timers **synchronously**, and wakes any `waitOn` sleepers. This is +how the unit test drives reconnection backoff and `connectionStateTtl` expiry **without real +sleeping**: +```kotlin +val fakeClock = FakeClock() +val client = TestRealtimeClient { enableFakeTimers(fakeClock); … } +… +fakeClock.advance(2.seconds) // jump forward; due timers fire now +``` +`pendingTaskCount(timerName)` lets you assert how many tasks are scheduled — useful for verifying +retry state. + +--- + +## 7. Proxy-Integration Infrastructure (real backend + fault injection) + +Proxy tests connect the **real SDK** to the **real Ably sandbox**, but route the traffic through a +small Go program — [`ably/uts-proxy`](https://github.com/ably/uts-proxy) — that can be told to inject +faults. Three Kotlin helpers make this work. + +### 7.1 `ProxyManager` — gets the proxy binary running +A singleton (`object`) responsible for the proxy *process*: +- Pins a proxy version (`v0.3.0`) and knows the **SHA-256 checksums** for each + OS/arch archive. +- `ensureProxy()` (called in `@BeforeAll`) is idempotent: if a proxy is already healthy on the + control port (**10100**) it's a no-op; otherwise it **downloads** the right + `uts-proxy___.tar.gz` from GitHub releases, **verifies the checksum**, extracts the + binary with a hand-rolled tar/gzip reader (JDK-only, no extra deps), caches it under + `~/.cache/uts-proxy//`, and launches it with `--port 10100`. +- The download is serialised **across JVMs** by a `FileLock` and **within a JVM** by a `Mutex`. + Because process startup shares the control port, `ProxyManager`'s KDoc **advises** running proxy + suites single-fork (`maxParallelForks = 1`) to avoid two Gradle workers racing to bind the control + port. ⚠️ Note: this is currently only a documented recommendation — it is **not** set in + `uts/build.gradle.kts`. With a single proxy test class today the race is not yet triggered, but it + should be configured before a second proxy suite is added. +- A **JVM shutdown hook** force-kills the spawned process on exit (a `ProcessBuilder` child does not + die with its parent). +- Override knob: set `-Duts.proxy.localPath=…` or `$UTS_PROXY_LOCAL_PATH` to use a **locally built** + proxy binary or `.tar.gz` (skips download + checksum). The build script forwards this property + into the test JVM. + +### 7.2 `ProxySession` — one test's window into the proxy +The proxy exposes a **control REST API** on the control port; `ProxySession` is the typed Kotlin +client for it (via Ktor). One session per test. + +- `ProxySession.create(rules, …)` → `POST /sessions` with a `target` (the sandbox realtime/REST + hosts) and an initial **rule list**; the proxy assigns a `sessionId` and a fresh **listening + port**. +- `addRules(rules, position)` → add rules mid-test (`POST /sessions/{id}/rules`). +- `triggerAction(action)` → fire an **imperative** action *right now* (`POST + /sessions/{id}/actions`) — e.g. inject a frame or drop the connection at a precise moment. +- `getLog()` → `GET /sessions/{id}/log`, returning a typed `List`. Each `Event` carries + `type` (`ws_connect`, `ws_frame`, `http_request`, …), `direction`, `queryParams`, and the parsed + protocol `message` (a `JsonObject`, introspected via `message?.get("action")?.asInt`). +- `close()` → `DELETE /sessions/{id}`, always called in a `finally`. + +**Rules** = `match` + `action` (+ optional `times`). Builder helpers keep tests readable: +`wsConnectRule`, `wsFrameToClientRule`, `wsFrameToServerRule`, `httpRequestRule`. Rules evaluate in +order, first match wins, unmatched traffic passes through, and `times: N` auto-removes a rule after N +firings. Common actions: `refuse_connection`, `suppress`, `replace`, `inject_to_client[_and_close]`, +`disconnect`, `http_respond`. + +**Wiring the client to the proxy** — the `connectThroughProxy(session)` extension does exactly what +the proxy spec prescribes: +```kotlin +fun ClientOptionsBuilder.connectThroughProxy(session: ProxySession) { + realtimeHost = session.proxyHost // "localhost" + restHost = session.proxyHost + port = session.proxyPort // the session's assigned port + tls = false // proxy serves plain HTTP/WS; TLS is only upstream +} +``` +Explicit hosts auto-disable fallback hosts (REC2c2), so no `fallbackHosts` juggling is needed. + +### 7.3 `SandboxApp` — a throwaway app on the real sandbox +Provisioning helper for the real backend (provisioned **directly**, not through the proxy, so it's +independent of the fault rules): +- `SandboxApp.create()` fetches the canonical `test-app-setup.json` from `ably-common`, + `POST`s it to `https://sandbox.realtime.ably-nonprod.net/apps`, and exposes `appId`, `defaultKey` + (full-capability `appId.keyId:keySecret`), and the full `keys` list. +- `delete()` removes the app in teardown (best-effort — errors are swallowed since sandbox apps + auto-expire). +- The Ktor client retries only **idempotent GETs** (never re-POSTs `/apps`, to avoid duplicate + apps). + +--- + +## 8. Shared Async Helpers + +`Utils.kt` provides the coroutine glue both tiers rely on. All three run on a **single-thread real +dispatcher** so their timeouts measure **wall-clock** time (not the virtual time of +`kotlinx.coroutines.test`). The two state-waiters (`awaitState`/`awaitChannelState`) register their +listener *before* checking current state, to avoid a check-then-register race; `pollUntil` has no +listener — it re-evaluates the predicate every `interval` until it holds or the timeout fires. + +| Helper | Signature | Purpose | +|--------|-----------|---------| +| `awaitState` | `(client, target, timeout=5s)` | suspend until `connection.state == target` (or already there) | +| `awaitChannelState` | `(channel, target, timeout=5s)` | same, for a channel's state | +| `pollUntil` | `(timeout=15s, interval=100ms) { condition }` | suspend until a boolean predicate holds — used in proxy tests to wait on real network/proxy state, e.g. `pollUntil { authCallbackCount.get() > original }` | + +`types/Utils.kt` adds one tiny convenience: a `ConnectionDetails { … }` builder DSL so tests can write +`ConnectionDetails { connectionKey = "key-1"; connectionStateTtl = 120000L }`. + +--- + +## 9. Walkthrough: the Unit Test (`ConnectionRecoveryTest`) + +**File:** `uts/.../realtime/unit/connection/ConnectionRecoveryTest.kt` +**Tier:** Unit (mocked WebSocket, no network). +**Spec area:** RTN16 — connection recovery via the `recover` option and `createRecoveryKey()`. + +It contains six tests; each carries an `@UTS realtime/unit/RTN16…/…` tag. Here's what each proves and +the technique it uses: + +### 9.1 `RTN16g, RTN16g1` — recovery-key structure (incl. Unicode) +Connects (mock returns CONNECTED with a known key), attaches two channels — one ASCII, one Unicode +(`channel-éàü-世界`) — feeding each an `ATTACHED` with a `channelSerial` via `sendToClient`. Then calls +`connection.createRecoveryKey()`, decodes it with `RecoveryKeyContext.decode`, and asserts the +connection key, `msgSerial == 0`, and both channel serials survive — including a full +**encode→decode round-trip** to prove the Unicode name isn't corrupted (RTN16g1). +*Technique: callback-style `onConnectionAttempt`, `sendToClient` for ATTACHED, `awaitChannelState`.* + +### 9.2 `RTN16g2` — `createRecoveryKey()` returns null in inactive states +The most elaborate test — it walks the connection through **five** states and asserts the key is null +in each inactive one: +- **INITIALIZED** (before connect) → null. +- **CONNECTED** → non-null (sanity). +- **CLOSING / CLOSED** → null (close nulls the key immediately). +- **FAILED** → null. *(Contains a documented **deviation** — see §11: the spec's fatal error + code 50000/500 isn't treated as fatal by the SDK, and `send_to_client_and_close` races the FAILED + transition; the test uses code 40000/400 and plain `sendToClient`.)* +- **SUSPENDED** → null. Built with a `FakeClock`: connect succeeds, then `simulateDisconnect()`, + then a coroutine **refuses every reconnection attempt** while `fakeClock.advance(2.seconds)` loops + until the short `connectionStateTtl` (800 ms) expires and the client gives up to SUSPENDED. +*Technique: this is the textbook example of **await-style** mocking — the first connection succeeds +via `awaitConnectionAttempt()`, but reconnections need the *refused* response, so a separate +`refuseJob` coroutine drives them; mixing this with fake timers gives deterministic SUSPENDED.* + +### 9.3 `RTN16k` — `recover` adds the `recover` query param +Constructs the client with `recover = `, captures `conn.queryParams` on each connection +attempt, then `simulateDisconnect()` and reconnect. Asserts the **first** attempt carries +`recover=` (and no `resume`), while the **second** (post-reconnect) carries `resume=` +(and no `recover`) — i.e. recover is a one-shot bootstrap, subsequent reconnections use resume. + +### 9.4 `RTN16f` — `recover` initialises `msgSerial` *(env-gated deviation)* +Asserts the recovered `msgSerial` (42) is preserved. The SDK resets it to 0, so the spec-correct +assertion `assertEquals(42L, …)` runs only under `RUN_DEVIATIONS`; otherwise a regression-guard +`assertEquals(0L, …)` runs. (See §11.) + +### 9.5 `RTN16f1` — malformed `recover` key degrades gracefully +`recover = "this-is-not-valid-json!!!"`. Asserts the client still connects normally with a fresh +identity, **no** `recover`/`resume` query params, and exactly one connection attempt — i.e. a bad key +is logged and ignored, not fatal. + +### 9.6 `RTN16j` — `recover` instantiates channels with their serials (RTN16i too) +Recovery key carries three channels (incl. Unicode). Asserts each `channels.get(name).properties. +channelSerial` matches the key, that the channels are **NOT auto-attached** (state INITIALIZED — +RTN16i), and that a manual `attach()` sends an ATTACH frame carrying the recovered serial (verified +via `awaitNextMessageFromClient()`). + +**What this test teaches about the infra:** callback vs await styles side by side, `FakeClock`-driven +SUSPENDED, `sendToClient` for server frames, `events`/`awaitNextMessageFromClient` for inspecting +client output, and the env-gated deviation pattern. + +--- + +## 10. Walkthrough: the Proxy Test (`AuthReauthTest`) + +**File:** `uts/.../realtime/integration/proxy/AuthReauthTest.kt` +**Tier:** Proxy integration (real sandbox + uts-proxy). +**Spec points:** RTN22 (server-initiated re-authentication) and RTC8a (the client sends an AUTH +frame with renewed auth details). Unit-test counterparts: `server_initiated_reauth_test.md`, +`realtime_authorize.md`. + +### 10.1 Suite setup/teardown +```kotlin +@TestInstance(TestInstance.Lifecycle.PER_CLASS) // one instance, so @BeforeAll can be non-static +class AuthReauthTest { + @BeforeAll fun setUpAll() = runBlocking { + ProxyManager.ensureProxy() // download+launch proxy if needed + app = SandboxApp.create() // provision a real sandbox app + } + @AfterAll fun tearDownAll() = runBlocking { if (::app.isInitialized) app.delete() } +} +``` + +### 10.2 The test, step by step +1. **Create a session with no rules** — the fault will be injected *imperatively* later (late + injection — the connect handshake runs against the real server unmodified): + ```kotlin + val session = ProxySession.create(rules = emptyList()) + ``` +2. **Auth via `authCallback`** — the spec generates a JWT from the sandbox key; the idiomatic + ably-java equivalent is a locally-signed `TokenRequest` from the same key (no external JWT + library). A counter records how many times the callback is invoked: + ```kotlin + val tokenSigner = AblyRest(app.defaultKey) + val authCallback = Auth.TokenCallback { params -> + authCallbackCount.incrementAndGet() + tokenSigner.auth.createTokenRequest(params, null) + } + ``` +3. **Build the client through the proxy** and connect (JSON stays on so the proxy can inspect + frames): + ```kotlin + val client = TestRealtimeClient { + this.authCallback = authCallback + connectThroughProxy(session) + autoConnect = false + } + client.connect() + awaitState(client, ConnectionState.connected, 15.seconds) + ``` +4. **Snapshot identity** — `connection.id` and the callback count, and assert the callback already + ran ≥ 1 (initial auth). +5. **Start recording state changes**, then **inject a server-initiated AUTH** (protocol action 17) + imperatively — simulating Ably asking the client to re-authenticate: + ```kotlin + session.triggerAction(mapOf("type" to "inject_to_client", + "message" to mapOf("action" to 17))) + ``` +6. **Wait for the re-auth round-trip** with `pollUntil { stateChanges.size > 1 }` (real network, so + poll — don't sleep). +7. **Assertions** prove RTN22 + RTC8a: + - `authCallback` was invoked **again** (count incremented) → re-auth was triggered. + - Connection is still **CONNECTED** and `connection.id` is **unchanged** → re-auth does not + reconnect. + - **No** transitions away from CONNECTED were recorded. + - The **proxy event log** contains a client→server **AUTH frame (action 17) carrying non-null + `auth` details** (RTC8a) — verified by filtering `session.getLog()`. +8. **Nested teardown** in `finally`: close the client and wait for CLOSED, then always close the + session and the token signer. + +**What this test teaches about the infra:** `ProxyManager.ensureProxy` + `SandboxApp` setup, +`connectThroughProxy`, **late imperative fault injection** via `triggerAction`, real-network waiting +with `pollUntil`, and **proxy-log assertions** as the primary verification (`getLog()` → +filter by `type`/`direction`/`message.action`). + +--- + +## 11. Deviations: when the SDK disagrees with the spec + +`uts/.../io/ably/lib/deviations.md` is the single catalogue of every place the ably-java SDK behaves +differently from the features spec, discovered during translation. Each entry records: the **spec +point**, **what the spec requires**, **what the SDK does**, the **root cause** (file/function, where +known), the **workaround in tests**, and the **affected tests**. + +The mechanism (from `writing-derived-tests.md`): the test keeps the **spec-correct** assertion but +gates it behind the `RUN_DEVIATIONS` env var, with a regression-guard assertion for the SDK's actual +behaviour running by default. Normal runs stay green; `RUN_DEVIATIONS=1` turns the failing assertions +on so the gap is reproducible and the test flips automatically once the SDK is fixed. + +Current entries relevant to the two tests: + +| Spec point | Gist | Touches | +|------------|------|---------| +| **RTN16f** | SDK resets `msgSerial` to 0 on connect even with `recover`; spec says preserve it (42). | `ConnectionRecoveryTest` (§9.4) — `assertEquals(42L,…)` gated, `assertEquals(0L,…)` default guard. | +| **RTN16g2** | Spec's fatal error 50000/500 isn't fatal to the SDK (`isFatalError()` needs code 40000–49999 or status < 500); also `send_to_client_and_close` races the FAILED transition. | `ConnectionRecoveryTest` (§9.2) — uses 40000/400 + plain `sendToClient`. | +| **RTL13b** | `ATTACHING → SUSPENDED` via `realtimeRequestTimeout` not implemented for channel attach. | various channel tests (not the two here). | +| **RTL13c** | `channelRetryTimeout` not cancelled when the connection leaves CONNECTED. | various channel tests; assertions gated behind `RUN_DEVIATIONS`. | + +> These deviations are **valuable output**, not failures — each one is a precise, reproducible bug +> report the SDK team can act on, and the gated test becomes the acceptance test for the fix. + +--- + +## 12. How to Run the Tests + +```bash +# All UTS tests (unit + proxy). Proxy suites download/launch the proxy automatically. +./gradlew :uts:test + +# Just the unit test class: +./gradlew :uts:test --tests "io.ably.lib.realtime.unit.connection.ConnectionRecoveryTest" + +# Just the proxy test class (needs network access to the sandbox + GitHub for the proxy binary): +./gradlew :uts:test --tests "io.ably.lib.realtime.integration.proxy.AuthReauthTest" + +# Turn on the spec-correct (currently failing) deviation assertions: +RUN_DEVIATIONS=1 ./gradlew :uts:test --tests "*ConnectionRecoveryTest*" + +# Run proxy tests against a locally built proxy instead of a GitHub release: +./gradlew :uts:test -Duts.proxy.localPath=/path/to/uts-proxy # or .tar.gz +# (equivalently: export UTS_PROXY_LOCAL_PATH=/path/to/uts-proxy) +``` + +Notes: +- `ProxyManager` **advises** running proxy suites single-fork (`maxParallelForks = 1`) because they + share the control port (10100). This is not currently set in `uts/build.gradle.kts`; it isn't + exercised yet because there is only one proxy test class. +- Proxy/sandbox tests need outbound network (sandbox + GitHub releases on first run; the binary is + then cached under `~/.cache/uts-proxy/`). +- Before pushing, run the project's static-analysis gate (from `CLAUDE.md`): + `./gradlew checkWithCodenarc checkstyleMain checkstyleTest` — Checkstyle is Java-only and easy to + miss; remember **no star imports**. + +--- + +## 13. Quick Reference / Cheat-Sheet + +**The two seams that make unit tests possible** (`DebugOptions`): +`webSocketEngineFactory` (WS), `httpEngine` (HTTP), `clock` (time). + +**Build a unit-test client:** +```kotlin +val mock = MockWebSocket { onConnectionAttempt = { it.respondWithSuccess(CONNECTED_MESSAGE) } } +val client = TestRealtimeClient { autoConnect = false; install(mock) } +client.connect(); awaitState(client, ConnectionState.connected) +``` + +**Build a proxy-test client:** +```kotlin +ProxyManager.ensureProxy(); val app = SandboxApp.create() +val session = ProxySession.create(rules = emptyList()) +val client = TestRealtimeClient { authCallback = …; connectThroughProxy(session); autoConnect = false } +``` + +**Server→client (mock):** `sendToClient` (stays open) · `sendToClientAndClose` (DISCONNECTED / +fatal ERROR) · `simulateDisconnect` (1006 drop). + +**Inspect what the SDK did:** `mock.events` (unit) · `session.getLog()` (proxy). + +**Wait (never sleep):** `awaitState` · `awaitChannelState` · `pollUntil { … }` · `FakeClock.advance(…)`. + +**Protocol action numbers** (used in rules & log assertions): CONNECTED=4, DISCONNECTED=6, ERROR=9, +ATTACH=10, ATTACHED=11, DETACH=12, DETACHED=13, **AUTH=17**. + +**Test ID format:** `//-` → +`@UTS realtime/proxy/RTN22/server-initiated-reauth-0`. + +**The decision tree when a translated test fails:** spec wrong → fix test + record UTS spec error; +translation wrong → fix test; SDK non-compliant → gate spec-correct assertion behind `RUN_DEVIATIONS` +and record in `deviations.md`. + +--- + +## 14. Appendix A: Request-Flow Diagrams + +### A.1 Unit test — mocked WebSocket (no network) + +A unit test installs `MockWebSocket` into `DebugOptions.webSocketEngineFactory`. The SDK believes it +is talking to a real socket; in fact every byte is intercepted by the mock and surfaced to the test. + +``` + ┌──────────────────────────────────── TEST (Kotlin coroutine) ────────────────────────────────────┐ + │ │ + │ TestRealtimeClient { install(mock); autoConnect = false } │ + │ │ client.connect() ▲ awaitState(client, connected) │ + │ ▼ │ │ + │ ┌───────────┐ webSocketEngineFactory ┌──────────────────────────┐ │ + │ │ AblyRealtime (SDK :java) │──────────▶ │ MockWebSocketEngineFactory │ (implements SDK SPI) │ + │ │ ConnectionManager, etc. │ └─────────────┬────────────┘ │ + │ └───────────┬────────────────┘ │ create() │ + │ │ send(frame) ───────────────────────────▶ │ │ + │ │ ▼ │ + │ │ ┌────────────────────────┐ │ + │ │ │ MockWebSocket │ │ + │ onMessage(frame) ◀───────────────────────│ • records MockEvent[] │ │ + │ ▲ │ • onConnectionAttempt │ ◀── PendingConnection ──┐ │ + │ │ │ • onMessageFromClient │ │ │ + │ │ └───────────┬────────────┘ │ │ + │ │ │ │ │ + │ TEST drives the "server" side: │ TEST inspects/responds: │ │ + │ mock.sendToClient(CONNECTED) ───────────────────────┘ pending.respondWithSuccess(msg) ───┘ │ + │ mock.sendToClientAndClose(DISCONNECTED) mock.awaitNextMessageFromClient() │ + │ mock.simulateDisconnect() mock.events (assert) │ + │ │ + │ FakeClock (DebugOptions.clock): fakeClock.advance(2.s) ── fires due timers synchronously │ + └───────────────────────────────────────────────────────────────────────────────────────────────┘ + + No TCP, no DNS, no real time. Everything is in-process and deterministic. +``` + +(The HTTP path is identical in shape: `MockHttpClient` → `DebugOptions.httpEngine` → +`MockHttpEngine` → `PendingConnection` then `PendingRequest`, with `respondWith(status, body)`.) + +### A.2 Proxy integration test — real backend through the fault-injecting proxy + +A proxy test uses the **real** SDK transport but points its host/port at the local `uts-proxy` +process, which forwards to the Ably sandbox and can inject faults on command. + +``` + ┌─────────────────── TEST (Kotlin) ───────────────────┐ + │ @BeforeAll: ProxyManager.ensureProxy() │ downloads/launches binary, control :10100 + │ SandboxApp.create() ─────────────────────────────────────────────┐ POST /apps (direct, TLS) + │ session = ProxySession.create(rules) ──────────── control REST :10100 ───┐ │ + │ client = TestRealtimeClient { connectThroughProxy(session) } │ │ + └──────────────┬───────────────────────────────────────────────────────────┘ │ + │ client.connect() (host=localhost, port=session.port, tls=false) │ + ▼ │ ▼ + ┌──────────────────┐ ws/http (plain) ┌───────────────────────┐ │ ┌───────────────────────┐ + │ AblyRealtime │ ◀──────────────────▶ │ uts-proxy │ ◀─┼▶│ Ably sandbox │ + │ (REAL transport) │ │ • forwards traffic │ │ │ sandbox.realtime. │ + └──────────────────┘ │ • applies rules │ │ │ ably-nonprod.net (TLS) │ + ▲ │ • records event log │ │ └───────────────────────┘ + │ TEST controls the proxy: └──────────┬────────────┘ │ + │ session.triggerAction({inject_to_client, action:17}) │ control REST :10100 + │ session.addRules([...]) │ + │ TEST verifies via: │ + │ session.getLog() ── filter type/direction/message.action ─┘ + │ awaitState(...) / pollUntil { ... } + └── (everything before the injected fault is REAL client↔server traffic) +``` + +**Why two channels to the proxy?** The **data plane** (the SDK's ws/http traffic on +`session.proxyPort`) is separate from the **control plane** (the test's REST calls on +`CONTROL_PORT = 10100` to create sessions, add rules, trigger actions, read the log). The SDK never +sees the control plane; the test never speaks the data plane directly. + +--- + +## 15. Appendix B: Per-File API Reference + +A one-stop table of every Kotlin source file under `uts/src/test/` and the SDK seams they use, so +nothing is left implicit. + +### B.1 Unit-test infrastructure — `io.ably.lib.uts.infra` + +| File | Key public surface | Role | +|------|--------------------|------| +| `ClientFactories.kt` | `ClientOptionsBuilder` (extends `DebugOptions`), `TestRealtimeClient { }`, `TestRestClient { }`, `install(mock)`, `enableFakeTimers(clock)` | Entry point for building a mocked SDK client; seeds dummy key, forces JSON. | +| `MockWebSocket.kt` | `MockWebSocket`, `WebSocketMockConfig` (`onConnectionAttempt`, `onMessageFromClient`, `onTextDataFrame`, `onBinaryDataFrame`), `events`, `installOn`, `awaitConnectionAttempt`, `awaitNextMessageFromClient`, `awaitClientClose`, `sendToClient`, `sendToClientAndClose`, `simulateDisconnect`, `reset`; top-level `MockWebSocket { }`, `CONNECTED_MESSAGE` | Fake realtime transport (callback + await styles). | +| `MockWebSocketEngineFactory.kt` | `MockWebSocketEngineFactory`, `MockWebSocketEngine`, `MockWebSocketClient` (implement `WebSocketEngineFactory`/`Engine`/`Client`) | Adapts the mock to the SDK's WebSocket SPI; parses URL → host/port/tls/query. | +| `MockHttpClient.kt` | `MockHttpClient`, `HttpMockConfig` (`onConnectionAttempt`, `onRequest`), `engine`, `installOn`, `awaitConnectionAttempt`, `awaitRequest`, `reset`; top-level `MockHttpClient { }` | Fake REST transport. | +| `MockHttpEngine.kt` | `MockHttpEngine`, `MockHttpCall`, `DefaultHttpPendingConnection` (implement `HttpEngine`/`HttpCall`) | Adapts the mock to the SDK's HTTP SPI; two-phase connect→request in `execute()`. | +| `PendingConnection.kt` | `interface PendingConnection` (`host`,`port`,`tls`,`queryParams`, `respondWithSuccess[ (message) ]`, `respondWithRefused/Timeout/DnsError`); plus the top-level helper `parseQueryString()` (not an interface member) | Abstract connection attempt awaiting a verdict (shared WS + HTTP). | +| `DefaultPendingConnection.kt` | `DefaultPendingConnection : PendingConnection` | WS impl; **async** CONNECTED delivery on `mock-ws-delivery` thread. | +| `PendingRequest.kt` | `interface PendingRequest` (`url`,`method`,`headers`,`body`, `respondWith`, `respondWithDelay`, `respondWithTimeout`) | Abstract in-flight HTTP request awaiting a response. | +| `DefaultPendingRequest.kt` | `DefaultPendingRequest : PendingRequest` | HTTP impl backed by a `CompletableDeferred`. | +| `MockEvent.kt` | `sealed class MockEvent`: `ConnectionAttempt`, `ConnectionEstablished`, `ConnectionRefused`, `ConnectionTimeout`, `DnsError`, `HttpRequest`, `SentToClient`, `Disconnected`, `ClientClose`, `MessageFromClient` | Ordered, typed log of everything that happened on a mock transport. | +| `FakeClock.kt` | `FakeClock : Clock` (`advance(ms\|Duration)`, `pendingTaskCount(name)`, `currentTimeMillis`, `nanoTime`, `newTimer`, `waitOn`) | Virtual clock + virtual timers; deterministic time. | + +### B.2 Proxy/sandbox infrastructure — `io.ably.lib.test.helper` + +| File | Key public surface | Role | +|------|--------------------|------| +| `ProxyManager.kt` | `object ProxyManager`: `ensureProxy(timeoutMs)`, `stopProxy()`, `CONTROL_PORT=10100`, `sandboxRealtimeHost`, `sandboxRestHost`; pinned `PROXY_VERSION=v0.3.0` + per-arch checksums; `uts.proxy.localPath` override | Downloads/verifies/launches the `uts-proxy` binary; one shared process per run. | +| `ProxySession.kt` | `class ProxySession` (`create(rules,port,timeoutMs,realtimeHost,restHost)`, `addRules`, `triggerAction`, `getLog(): List`, `close`, `sessionId`, `proxyPort`, `proxyHost`); `data class Event`; `typealias ProxyRule`; rule builders `wsConnectRule`/`wsFrameToClientRule`/`wsFrameToServerRule`/`httpRequestRule`; `ClientOptionsBuilder.connectThroughProxy(session)` | Typed client for the proxy control REST API + client wiring. | +| `SandboxApp.kt` | `class SandboxApp` (`create()`, `delete()`, `appId`, `defaultKey`, `keys`) | Provisions/tears down a throwaway sandbox app from `ably-common`'s `test-app-setup.json`. | + +### B.3 Shared helpers & tests + +| File | Key public surface | Role | +|------|--------------------|------| +| `io/ably/lib/Utils.kt` | `awaitState(client,target,timeout=5s)`, `awaitChannelState(channel,target,timeout=5s)`, `pollUntil(timeout=15s,interval=100ms){ }` | Wall-clock coroutine waits; listener registered before state check. | +| `io/ably/lib/types/Utils.kt` | `ConnectionDetails { }` builder | DSL sugar for building `ConnectionDetails` in tests. | +| `realtime/unit/connection/ConnectionRecoveryTest.kt` | 6 `@Test`s: RTN16g/g1, RTN16g2, RTN16k, RTN16f, RTN16f1, RTN16j | Unit tier — connection recovery (mocked WS, FakeClock, env-gated deviations). | +| `realtime/integration/proxy/AuthReauthTest.kt` | 1 `@Test` (two `@UTS`: RTN22, RTC8a) | Proxy tier — server-initiated re-authentication. | +| `deviations.md` | RTN16f, RTN16g2, RTL13b, RTL13c | Catalogue of SDK-vs-spec divergences. | + +> **Coverage note:** at the time of writing, the `uts/` module contains exactly **two test classes** +> (**7** `@Test` methods total: 6 in `ConnectionRecoveryTest` + 1 in `AuthReauthTest`). The infrastructure under +> `uts/infra/` and `test/helper/` is built out far beyond what these two tests exercise (full HTTP +> mock, all four rule builders, REST proxy wiring, etc.), anticipating the broader UTS coverage +> catalogued in `completion-status.md`. + +--- + +### Source map (where each fact in this doc comes from) + +| Topic | File | +|-------|------| +| Authoring portable specs, test IDs, mock pseudocode | `ably-specification/.../uts/docs/writing-test-specs.md` | +| Translating specs, deviation patterns, decision tree | `…/uts/docs/writing-derived-tests.md` | +| Integration/proxy policy, late fault injection, tiers | `…/uts/docs/integration-testing.md` | +| Coverage matrix | `…/uts/docs/completion-status.md` | +| Proxy control API, rule format, action numbers | `…/uts/realtime/integration/helpers/proxy.md` | +| SDK seams | `lib/.../debug/DebugOptions.java`, `lib/.../util/Clock.java` | +| Module wiring | `uts/build.gradle.kts`, `settings.gradle.kts` | +| Unit mocks | `uts/.../uts/infra/*` | +| Proxy/sandbox helpers | `uts/.../test/helper/*` | +| Async helpers | `uts/.../io/ably/lib/Utils.kt`, `…/types/Utils.kt` | +| The two example tests | `…/unit/connection/ConnectionRecoveryTest.kt`, `…/integration/proxy/AuthReauthTest.kt` | +| Deviations | `uts/.../io/ably/lib/deviations.md` | diff --git a/website.html b/website.html new file mode 100644 index 000000000..8b788b12a --- /dev/null +++ b/website.html @@ -0,0 +1,804 @@ + + + + + +UTS in ably-java — A Human-Readable Guide + + + + + +

UTS in ably-java — Guide
+ +
+ + +
+
+
Ably SDK Testing
+

UTS in ably-java
A Human-Readable Guide

+

A practical, end-to-end explanation of the Universal Test Specification (UTS) and exactly how the Java/Kotlin code under uts/ makes the unit and proxy-integration tests work — written for a developer who has never touched UTS before.

+ + +
+ + + + Ably features spec + features.md + RSC* · RTN* · RTL* + + + distilled + + + UTS test specs + portable .md pseudocode + source of truth: WHAT + + + derived + + + Derived tests + Kotlin in uts/ + ConnectionRecoveryTest.kt + + +
From the one behavioural contract (features spec) → portable specs → per-SDK runnable tests.
+
+
+ + +
+

1 Introduction: What is UTS?

+

UTS (Universal Test Specification) is Ably's language-neutral catalogue of tests for its client SDKs. The problem it solves: Ably ships many SDKs (JavaScript, Dart, Kotlin/Java, Swift, Go…), and every one of them must obey the same behavioural contract — the Ably features spec (specification/specifications/features.md, whose requirements are tagged RSC7, RTN15a, RTL4f…). Without a shared test definition, each SDK would re-invent its own tests, drift apart, and leave gaps.

+

UTS fixes this by separating what to test from how to test it in a given language (see the pipeline above).

+

Three concepts you will see constantly:

+ + + + + + +
TermMeaning
Spec pointA tagged requirement in the features spec, e.g. RTN16g, RTN22, RTL4f. Test names embed these.
UTS specA markdown file of portable pseudocode describing setup, steps, and assertions for one feature. The source of truth for what to test.
Derived testA faithful translation of a UTS spec into a real test in a specific SDK/language. This is what lives in ably-java/uts/.
DeviationA documented case where the SDK's actual behaviour diverges from the spec. Recorded in deviations.md.
+
Golden rule +

From writing-derived-tests.md: translate the UTS spec faithfully — same structure, same assertions, same naming — don't optimise or skip steps. Every derived test carries a // UTS: <id> (here @UTS …) comment linking it back to its spec.

+
+ + +
+

2 The Three Test Tiers

+

UTS divides tests into three tiers by what infrastructure they need and what confidence they give. This split is the key to understanding the whole uts/ module — the two example tests sit in two different tiers.

+
+

Unit mocked

Transport: mocked (MockWebSocket, MockHttpClient). Backend: none.
Client-side logic: state machines, request formation, response parsing, timers. Fast & deterministic.
→ ConnectionRecoveryTest.kt

+

Direct sandbox real net

Transport: real. Backend: real Ably sandbox.
Happy-path interop: connect, publish, subscribe. No fault injection.
(not among the two example tests)

+

Proxy integration faults

Transport: real, through a programmable proxy. Backend: real sandbox.
Fault behaviour: dropped connections, injected errors, timeouts, re-auth.
→ AuthReauthTest.kt

+
+

Key principles (from integration-testing.md):

+
    +
  • Integration tests do not replace unit tests. A spec point covered by a proxy test should also have a unit test. The unit test proves client logic; the proxy test proves client & real server agree.
  • +
  • Proxy tests prefer "late fault injection". Let the real handshake complete against the real server, then inject the fault as the final interaction — maximising how much of the test exercises genuine client-server behaviour.
  • +
  • Proxy tests always use JSON (useBinaryProtocol = false). Two reasons in the spec corpus: the proxy only supports text WebSocket frames so it can't inspect/modify msgpack (integration-testing.md), and the SDK under test doesn't implement msgpack (helpers/proxy.md).
  • +
+
+ + +
+

3 The UTS Documents (the source of truth)

+

These four documents live in the specification repo at specification/uts/docs/. They are the policy/authoring guides; the Kotlin code in this repo is their implementation.

+ +

3.1 writing-test-specs.md — how to author a portable UTS spec

+
    +
  • Test types (unit / integration / proxy) and when each applies.
  • +
  • Test IDs — format <category>/<spec-point>/<descriptive-name>-<n>, e.g. realtime/proxy/RTN22/server-initiated-reauth-0. These IDs are the @UTS comments in the Kotlin tests.
  • +
  • Mock infrastructure pseudocode interfacesMockHttpClient, MockWebSocket, PendingConnection, PendingRequest with respond_with_success(), send_to_client(), simulate_disconnect(). The Kotlin classes in uts/infra/ realise these.
  • +
  • Handler vs await patterns for mocks (see §6).
  • +
  • WebSocket closing semantics — the crucial rule: send_to_client_and_close() for DISCONNECTED / connection-level ERROR (server closes the socket); send_to_client() for a channel-level ERROR (connection stays open).
  • +
  • Anti-flake conventions — no fixed WAITs; use polling, AWAIT_STATE, fake timers, and the record-and-verify pattern (CONTAINS_IN_ORDER) for transient states.
  • +
+ +

3.2 writing-derived-tests.md — how to translate a spec into a real SDK test

+

Two phases: Translation (always) — faithfully render the spec into the target language, map pseudocode to the SDK's API & test framework, flag ambiguities, ensure it compiles. Evaluation (when an implementation exists) — run the test and, if it fails, work the decision tree:

+
+ + Test fails + + Does UTS spec matchthe features spec? + + NO + fix test +record UTS error + + YES + Does test accuratelytranslate the spec? + NO + fix the test + YES + SDK deviation → adapt + record in deviations.md + + + +
The three-branch decision tree from writing-derived-tests.md.
+
+

It also defines the env-gated skip pattern (RUN_DEVIATIONS) — the test holds the spec-correct assertion but only runs it when the env var is set, so normal runs stay green while each deviation stays individually reproducible. This is exactly what ConnectionRecoveryTest uses for RTN16f.

+ +

3.3 integration-testing.md — the policy for integration & proxy tests

+

Defines what deserves an integration test (request/response interop, error interop, data round-trips, stateful protocol sequences), the directory layout, sandbox provisioning, proxy session lifecycle, timeout strategy, and the late-fault-injection philosophy. The integration/proxy/ segregation exists because proxy tests have different infra needs, CI cadence, and failure modes.

+ +

3.4 completion-status.md — the coverage matrix

+

A big table mapping every features-spec group (RSC, RTN, RTL, RTP…) to the UTS specs that cover it, with a per-tier summary (unit:✓ proxy:✓). The two example tests correspond to these rows:

+
    +
  • RTN16 (connection recovery) → unit spec connection_recovery_test.mdConnectionRecoveryTest.kt.
  • +
  • RTN22 / RTC8a (server-initiated re-auth) → proxy spec realtime/integration/proxy/auth_reauth.mdAuthReauthTest.kt.
  • +
+
Fifth, referenced doc +

There is also realtime/integration/helpers/proxy.md. It defines the proxy's control API, rule format, action types, and the protocol message action-number table (CONNECTED=4, ATTACH=10, AUTH=17…). The Kotlin ProxySession is the client for exactly that API.

+
+ + +
+

4 The Java Setup: the uts/ module

+

The uts/ directory is a standalone Gradle module (include("uts") in settings.gradle.kts) whose only job is to host UTS-derived tests. It contains no production code — everything lives under src/test/.

+ +

4.1 uts/build.gradle.kts

+
plugins { alias(libs.plugins.kotlin.jvm) }
+
+dependencies {
+    testImplementation(project(":java"))                 // the SDK under test
+    testImplementation(project(":network-client-core"))  // HttpEngine / WebSocketEngine interfaces
+    testImplementation(kotlin("test"))
+    testImplementation(libs.mockk)
+    testImplementation(libs.coroutine.core)              // kotlinx.coroutines
+    testImplementation(libs.coroutine.test)              // runTest, virtual time
+    testImplementation(libs.ktor.client.core)            // HTTP client for proxy/sandbox control
+    testImplementation(libs.ktor.client.cio)
+}
+
+tasks.withType<Test>().configureEach {
+    useJUnitPlatform()                                   // JUnit 5
+    jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED")
+    jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
+    // Propagate a local proxy build override (see ProxyManager):
+    systemProperty("uts.proxy.localPath", /* -Duts.proxy.localPath=… or $UTS_PROXY_LOCAL_PATH */ …)
+}
+

Takeaways:

+
    +
  • Tests are Kotlin + JUnit 5, using kotlinx.coroutines for async control and Ktor as the HTTP client that talks to the sandbox REST API and the proxy control API.
  • +
  • Depends on :java (the SDK) and :network-client-core (the pluggable transport interfaces the mocks implement).
  • +
  • The --add-opens java.base/java.time and java.base/java.lang flags grant reflective access for the test runtime. They mirror the same flags in java/build.gradle.kts (which additionally opens java.net and java.lang.reflect).
  • +
  • A system property carries an optional path to a locally built proxy binary.
  • +
+ +

4.2 Directory layout

+
uts/src/test/kotlin/io/ably/lib/
+├── Utils.kt                         # awaitState / awaitChannelState / pollUntil
+├── types/Utils.kt                   # ConnectionDetails { … } builder DSL
+├── deviations.md                    # the catalogue of SDK-vs-spec divergences
+│
+├── uts/infra/                       # ── UNIT-TEST INFRASTRUCTURE (mocked transports) ──
+│   ├── ClientFactories.kt           #   TestRealtimeClient / TestRestClient / ClientOptionsBuilder
+│   ├── MockWebSocket.kt             #   fake WS transport + WebSocketMockConfig + CONNECTED_MESSAGE
+│   ├── MockWebSocketEngineFactory.kt#   plugs the mock into the SDK's WebSocketEngine SPI
+│   ├── MockHttpClient.kt            #   fake HTTP engine + HttpMockConfig
+│   ├── MockHttpEngine.kt            #   plugs the mock into the SDK's HttpEngine SPI
+│   ├── MockEvent.kt                 #   sealed log of everything on a mock transport
+│   ├── PendingConnection.kt         #   interface: a connection attempt awaiting a response
+│   ├── DefaultPendingConnection.kt  #   WS implementation of PendingConnection
+│   ├── PendingRequest.kt            #   interface: an in-flight HTTP request awaiting a response
+│   ├── DefaultPendingRequest.kt     #   HTTP implementation of PendingRequest
+│   └── FakeClock.kt                 #   virtual clock + virtual timers (deterministic time)
+│
+├── test/helper/                     # ── PROXY-INTEGRATION INFRASTRUCTURE (real backend) ──
+│   ├── ProxyManager.kt              #   downloads/launches the uts-proxy binary
+│   ├── ProxySession.kt              #   one proxy session + connectThroughProxy
+│   └── SandboxApp.kt                #   provisions/deletes a sandbox app
+│
+└── realtime/
+    ├── unit/connection/
+    │   └── ConnectionRecoveryTest.kt        # ← the UNIT test (RTN16*)
+    └── integration/proxy/
+        └── AuthReauthTest.kt                # ← the PROXY test (RTN22, RTC8a)
+
Mental model

uts/infra/ powers unit tests · test/helper/ powers proxy tests · Utils.kt serves both.

+
+ + +
+

5 How a Test Reaches the SDK: the hook points

+

A test can only mock transports because the SDK was designed with pluggable seams. They live on io.ably.lib.debug.DebugOptions (a subclass of ClientOptions):

+
public class DebugOptions extends ClientOptions {
+    public HttpEngine httpEngine;                         // ← MockHttpClient installs here
+    public WebSocketEngineFactory webSocketEngineFactory; // ← MockWebSocket installs here
+    public Clock clock;                                   // ← FakeClock installs here
+    …
+}
+

and the Clock interface:

+
public interface Clock {
+    long currentTimeMillis();
+    long nanoTime();
+    AblyTimer newTimer(String name);                      // every SDK timer is created through this
+    void waitOn(Object target, long timeout) throws InterruptedException; // every blocking wait
+}
+

So the recipe is:

+
    +
  • Fake the WebSocket? Set webSocketEngineFactory to a factory that produces a mock engine.
  • +
  • Fake HTTP? Set httpEngine to a mock engine.
  • +
  • Control time (timeouts, retries, TTL expiry) deterministically? Set clock to a FakeClock.
  • +
+

The ClientOptionsBuilder wraps all three so tests never touch DebugOptions directly.

+
+ + +
+

6 Unit-Test Infrastructure (mocked transports)

+ +

6.1 The client builder — ClientFactories.kt

+
class ClientOptionsBuilder : DebugOptions("appId.keyId:keySecret") {
+    init { useBinaryProtocol = false }                  // JSON so mocks can decode frames
+    fun install(mock: MockWebSocket) = mock.installOn(this)
+    fun install(mock: MockHttpClient) = mock.installOn(this)
+    fun enableFakeTimers(fakeClock: FakeClock) { clock = fakeClock }
+}
+
+fun TestRealtimeClient(block: ClientOptionsBuilder.() -> Unit): AblyRealtime =
+    AblyRealtime(ClientOptionsBuilder().apply(block))
+fun TestRestClient(block: ClientOptionsBuilder.() -> Unit): AblyRest =
+    AblyRest(ClientOptionsBuilder().apply(block))
+
    +
  • Seeds a dummy API key (appId.keyId:keySecret) — fine, because unit tests never hit a real server and tokens are opaque.
  • +
  • Forces JSON so the mock can parse protocol frames.
  • +
  • install(mock) / enableFakeTimers(clock) wire the seams from §5.
  • +
+
val mock = MockWebSocket { onConnectionAttempt = { it.respondWithSuccess(CONNECTED_MESSAGE) } }
+val client = TestRealtimeClient {
+    autoConnect = false
+    install(mock)
+}
+ +

6.2 MockWebSocket — the fake realtime transport

+

The heart of realtime unit testing. It plugs into the SDK via MockWebSocketEngineFactory (which implements the SDK's WebSocketEngineFactory SPI) and exposes two complementary control styles:

+

(a) Callback style — handle inline, synchronously on the SDK thread

+
val mock = MockWebSocket {
+    onConnectionAttempt = { conn -> conn.respondWithSuccess(CONNECTED_MESSAGE) }
+    onMessageFromClient  = { msg -> /* inspect frames the SDK sent */ }
+}
+

Best when every connection attempt should behave the same way.

+

(b) Await style — suspend until the SDK triggers something, then respond

+
val pending = mock.awaitConnectionAttempt()      // suspend until SDK opens a socket
+pending.respondWithRefused()                     // …then decide how to answer
+val frame = mock.awaitNextMessageFromClient()    // suspend until SDK sends a frame
+

Required when the first connection and a reconnection need different answers (e.g. "connect succeeds, then all retries are refused" — the SUSPENDED scenario in the unit test).

+
⚠ Cannot mix styles

You cannot mix callback and await for the same event type — a callback consumes the event before the queue ever sees it.

+

Server → client direction (driving the SDK), matching the spec's closing semantics:

+ + + + + +
MethodWhat it doesUse for
sendToClient(msg)deliver a frame, connection stays openCONNECTED, ATTACHED, channel-level ERROR, normal messages
sendToClientAndClose(msg)deliver a frame then close (code 1000)DISCONNECTED, connection-level ERROR (fatal)
simulateDisconnect()close with code 1006, no messageunexpected network drop → triggers DISCONNECTED/resume
+

Everything is logged. mock.events is an ordered List<MockEvent> (a sealed class: ConnectionAttempt, ConnectionEstablished, ConnectionRefused, SentToClient, MessageFromClient, ClientClose, Disconnected…). Tests assert against it.

+

CONNECTED_MESSAGE is a ready-to-use CONNECTED ProtocolMessage (connectionId test-connection-id, a connection key, TTL 120 s, max-idle 15 s). It is a val with a custom getter, so each access returns a fresh instance — not a shared singleton; safe to mutate per test via CONNECTED_MESSAGE.apply { … }.

+
Subtlety

DefaultPendingConnection.respondWithSuccess(message) delivers the CONNECTED frame asynchronously on a separate mock-ws-delivery thread. That mirrors reality — the SDK must store the WebSocket reference before it processes CONNECTED.

+ +

6.3 MockHttpClient — the fake REST transport

+

The HTTP analogue, plugged in via MockHttpEngine (implements the SDK's HttpEngine SPI). Same two styles. A request flows in two phases inside MockHttpCall.execute():

+
    +
  1. Connect phase → produces a PendingConnection (respondWithSuccess/Refused/Timeout/DnsError).
  2. +
  3. Request phase → produces a PendingRequest exposing url, method, headers, body, and respondWith(status, body, headers) / respondWithDelay(...) / respondWithTimeout().
  4. +
+

This lets REST unit tests assert on outgoing request shape and feed canned responses back — without a socket.

+ +

6.4 FakeClock — deterministic time

+

Implements the SDK's Clock. Time is frozen until you call advance(ms); on each advance it fires any due virtual timers synchronously and wakes any waitOn sleepers. This drives reconnection backoff and connectionStateTtl expiry without real sleeping:

+
val fakeClock = FakeClock()
+val client = TestRealtimeClient { enableFakeTimers(fakeClock); … }
+…
+fakeClock.advance(2.seconds)      // jump forward; due timers fire now
+

pendingTaskCount(timerName) lets you assert how many tasks are scheduled — useful for verifying retry state.

+
+ + +
+

7 Proxy-Integration Infrastructure (real backend + faults)

+

Proxy tests connect the real SDK to the real Ably sandbox, but route traffic through a small Go program — ably/uts-proxy — that can be told to inject faults. Three Kotlin helpers make this work.

+ +

7.1 ProxyManager — gets the proxy binary running

+
    +
  • Pins a proxy version (v0.3.0) and knows the SHA-256 checksums for each OS/arch archive.
  • +
  • ensureProxy() (in @BeforeAll) is idempotent: if a proxy is healthy on the control port (10100) it's a no-op; otherwise it downloads the right archive from GitHub releases, verifies the checksum, extracts the binary with a hand-rolled tar/gzip reader (JDK-only), caches it under ~/.cache/uts-proxy/<version>/, and launches it with --port 10100.
  • +
  • The download is serialised across JVMs by a FileLock and within a JVM by a Mutex.
  • +
  • A JVM shutdown hook force-kills the spawned process on exit.
  • +
  • Override knob: set -Duts.proxy.localPath=… or $UTS_PROXY_LOCAL_PATH to use a locally built proxy binary or .tar.gz (skips download + checksum).
  • +
+
⚠ Single-fork advisory

Because process startup shares the control port, ProxyManager's KDoc advises running proxy suites single-fork (maxParallelForks = 1). Note: this is currently only a documented recommendation — it is not set in uts/build.gradle.kts. With a single proxy test class today the race is not yet triggered, but it should be configured before a second proxy suite is added.

+ +

7.2 ProxySession — one test's window into the proxy

+

The proxy exposes a control REST API on the control port; ProxySession is the typed Kotlin client for it (via Ktor). One session per test.

+ + + + + + + +
MethodControl endpointPurpose
ProxySession.create(rules, …)POST /sessionswith a target (sandbox hosts) + initial rule list; proxy assigns a sessionId & fresh listening port.
addRules(rules, position)POST /sessions/{id}/rulesadd rules mid-test.
triggerAction(action)POST /sessions/{id}/actionsfire an imperative action right now (inject a frame / drop connection).
getLog()GET /sessions/{id}/logreturns a typed List<Event>.
close()DELETE /sessions/{id}always in a finally.
+

Each Event carries type (ws_connect, ws_frame, http_request…), direction, queryParams, and the parsed protocol message (a JsonObject, introspected via message?.get("action")?.asInt).

+

Rules = match + action (+ optional times). Builder helpers keep tests readable: wsConnectRule, wsFrameToClientRule, wsFrameToServerRule, httpRequestRule. Rules evaluate in order, first match wins, unmatched traffic passes through, and times: N auto-removes a rule after N firings. Common actions: refuse_connection, suppress, replace, inject_to_client[_and_close], disconnect, http_respond.

+

Wiring the client to the proxyconnectThroughProxy(session) does exactly what the proxy spec prescribes:

+
fun ClientOptionsBuilder.connectThroughProxy(session: ProxySession) {
+    realtimeHost = session.proxyHost   // "localhost"
+    restHost     = session.proxyHost
+    port         = session.proxyPort   // the session's assigned port
+    tls          = false               // proxy serves plain HTTP/WS; TLS is only upstream
+}
+

Explicit hosts auto-disable fallback hosts (REC2c2), so no fallbackHosts juggling is needed.

+ +

7.3 SandboxApp — a throwaway app on the real sandbox

+
    +
  • SandboxApp.create() fetches the canonical test-app-setup.json from ably-common (specifically its post_apps sub-object), POSTs it to https://sandbox.realtime.ably-nonprod.net/apps, and exposes appId, defaultKey (full-capability appId.keyId:keySecret, from the keyStr field), and the full keys list.
  • +
  • delete() removes the app in teardown (best-effort — errors swallowed, sandbox apps auto-expire).
  • +
  • The Ktor client retries only idempotent GETs (never re-POSTs /apps, to avoid duplicate apps).
  • +
+

The app is provisioned directly (not through the proxy), so it's independent of the fault rules under test.

+
+ + +
+

8 Shared Async Helpers

+

Utils.kt provides the coroutine glue both tiers rely on. All three run on a single-thread real dispatcher so their timeouts measure wall-clock time (not the virtual time of kotlinx.coroutines.test). The two state-waiters register their listener before checking current state, to avoid a check-then-register race; pollUntil has no listener — it re-evaluates the predicate every interval.

+ + + + + +
HelperSignaturePurpose
awaitState(client, target, timeout=5s)suspend until connection.state == target (or already there)
awaitChannelState(channel, target, timeout=5s)same, for a channel's state
pollUntil(timeout=15s, interval=100ms) { cond }suspend until a boolean predicate holds — used in proxy tests to wait on real network/proxy state
+

types/Utils.kt adds a ConnectionDetails { … } builder DSL so tests can write ConnectionDetails { connectionKey = "key-1"; connectionStateTtl = 120000L }.

+
+ + +
+

9 Walkthrough: the Unit Test (ConnectionRecoveryTest)

+

Tier: Unit (mocked WebSocket, no network). Spec area: RTN16 — connection recovery via the recover option and createRecoveryKey(). Six tests, each tagged @UTS realtime/unit/RTN16…/….

+ +

9.1 RTN16g, RTN16g1 — recovery-key structure (incl. Unicode)

+

Connects, attaches two channels — one ASCII, one Unicode (channel-éàü-世界) — feeding each an ATTACHED with a channelSerial. Calls createRecoveryKey(), decodes it, asserts the connection key, msgSerial == 0, and both channel serials survive — including a full encode→decode round-trip proving the Unicode name isn't corrupted (RTN16g1). Technique: callback-style onConnectionAttempt, sendToClient for ATTACHED, awaitChannelState.

+ +

9.2 RTN16g2createRecoveryKey() returns null in inactive states

+

The most elaborate test — walks the connection through five lifecycle states, asserting the key is null in each inactive one:

+
    +
  • INITIALIZED (before connect) → null.
  • +
  • CONNECTED → non-null (sanity).
  • +
  • CLOSING / CLOSED → null (close nulls the key immediately).
  • +
  • FAILED → null. (Documented deviation — see §11.)
  • +
  • SUSPENDED → null. Built with a FakeClock: connect succeeds, then simulateDisconnect(), then a coroutine refuses every reconnection attempt while fakeClock.advance(2.seconds) loops until the short connectionStateTtl (800 ms) expires and the client gives up to SUSPENDED.
  • +
+

Technique: the textbook await-style example — the first connection succeeds via awaitConnectionAttempt(), but reconnections need the refused response, so a separate refuseJob coroutine drives them; combined with fake timers this gives a deterministic SUSPENDED.

+ +

9.3 RTN16krecover adds the recover query param

+

Constructs the client with recover = <recoveryKey>, captures conn.queryParams on each attempt, then simulateDisconnect() + reconnect. Asserts the first attempt carries recover=<key> (no resume), the second carries resume=<new key> (no recover) — recover is a one-shot bootstrap; subsequent reconnections use resume.

+ +

9.4 RTN16frecover initialises msgSerial env-gated deviation

+

Asserts the recovered msgSerial (42) is preserved. The SDK resets it to 0, so the spec-correct assertion assertEquals(42L, …) runs only under RUN_DEVIATIONS; otherwise a regression-guard assertEquals(0L, …) runs. (See §11.)

+ +

9.5 RTN16f1 — malformed recover key degrades gracefully

+

recover = "this-is-not-valid-json!!!". Asserts the client still connects normally with a fresh identity, no recover/resume params, and exactly one connection attempt — a bad key is logged and ignored, not fatal.

+ +

9.6 RTN16jrecover instantiates channels with serials (RTN16i too)

+

Recovery key carries three channels (incl. Unicode). Asserts each channels.get(name).properties.channelSerial matches the key, that channels are NOT auto-attached (state INITIALIZED — RTN16i), and that a manual attach() sends an ATTACH frame carrying the recovered serial (verified via awaitNextMessageFromClient()).

+
What this teaches

Callback vs await styles side by side, FakeClock-driven SUSPENDED, sendToClient for server frames, events/awaitNextMessageFromClient for inspecting client output, and the env-gated deviation pattern.

+
+ + +
+

10 Walkthrough: the Proxy Test (AuthReauthTest)

+

Tier: Proxy integration (real sandbox + uts-proxy). Spec points: RTN22 (server-initiated re-authentication) and RTC8a (client sends an AUTH frame with renewed auth details). Unit counterparts: server_initiated_reauth_test.md, realtime_authorize.md.

+ +

10.1 Suite setup/teardown

+
@TestInstance(TestInstance.Lifecycle.PER_CLASS)   // one instance, so @BeforeAll can be non-static
+class AuthReauthTest {
+    @BeforeAll fun setUpAll() = runBlocking {
+        ProxyManager.ensureProxy()                // download+launch proxy if needed
+        app = SandboxApp.create()                 // provision a real sandbox app
+    }
+    @AfterAll fun tearDownAll() = runBlocking { if (::app.isInitialized) app.delete() }
+}
+ +

10.2 The test, step by step

+
    +
  1. Create a session with no rules — the fault is injected imperatively later (late injection — the handshake runs against the real server unmodified): val session = ProxySession.create(rules = emptyList())
  2. +
  3. Auth via authCallback — a locally-signed TokenRequest from the same sandbox key (no external JWT library). A counter records how many times the callback runs: +
    val tokenSigner = AblyRest(app.defaultKey)
    +val authCallback = Auth.TokenCallback { params ->
    +    authCallbackCount.incrementAndGet()
    +    tokenSigner.auth.createTokenRequest(params, null)
    +}
  4. +
  5. Build the client through the proxy and connect (JSON stays on so the proxy can inspect frames): +
    val client = TestRealtimeClient {
    +    this.authCallback = authCallback
    +    connectThroughProxy(session)
    +    autoConnect = false
    +}
    +client.connect()
    +awaitState(client, ConnectionState.connected, 15.seconds)
  6. +
  7. Snapshot identityconnection.id and the callback count; assert the callback already ran ≥ 1 (initial auth).
  8. +
  9. Start recording state changes, then inject a server-initiated AUTH (protocol action 17) imperatively: +
    session.triggerAction(mapOf("type" to "inject_to_client",
    +                            "message" to mapOf("action" to 17)))
  10. +
  11. Wait for the re-auth round-trip with pollUntil { stateChanges.size > 1 } (real network, so poll — don't sleep).
  12. +
  13. Assertions prove RTN22 + RTC8a: +
      +
    • authCallback was invoked again (count incremented) → re-auth triggered.
    • +
    • Connection is still CONNECTED and connection.id is unchanged → re-auth does not reconnect.
    • +
    • No transitions away from CONNECTED were recorded.
    • +
    • The proxy event log contains a client→server AUTH frame (action 17) carrying non-null auth details (RTC8a) — verified by filtering session.getLog().
    • +
    +
  14. +
  15. Nested teardown in finally: close the client and wait for CLOSED, then always close the session and the token signer.
  16. +
+
What this teaches

ProxyManager.ensureProxy + SandboxApp setup, connectThroughProxy, late imperative fault injection via triggerAction, real-network waiting with pollUntil, and proxy-log assertions as the primary verification.

+
+ + +
+

11 Deviations: when the SDK disagrees with the spec

+

uts/.../deviations.md is the single catalogue of every place the ably-java SDK behaves differently from the features spec, discovered during translation. Each entry records: the spec point, what the spec requires, what the SDK does, the root cause (file/function, where known), the workaround in tests, and the affected tests.

+

The mechanism: the test keeps the spec-correct assertion but gates it behind the RUN_DEVIATIONS env var, with a regression-guard assertion for the SDK's actual behaviour running by default. Normal runs stay green; RUN_DEVIATIONS=1 turns the failing assertions on, and the test flips automatically once the SDK is fixed.

+ + + + + + +
Spec pointGistTouches
RTN16fSDK resets msgSerial to 0 on connect even with recover; spec says preserve it (42).ConnectionRecoveryTest (§9.4) — assertEquals(42L,…) gated, assertEquals(0L,…) default guard.
RTN16g2Spec's fatal error 50000/500 isn't fatal to the SDK (isFatalError() needs code 40000–49999 or status < 500); also send_to_client_and_close races the FAILED transition.ConnectionRecoveryTest (§9.2) — uses 40000/400 + plain sendToClient.
RTL13bATTACHING → SUSPENDED via realtimeRequestTimeout not implemented for channel attach.various channel tests (not the two here).
RTL13cchannelRetryTimeout not cancelled when the connection leaves CONNECTED.various channel tests; assertions gated behind RUN_DEVIATIONS.
+
Why this matters

These deviations are valuable output, not failures — each one is a precise, reproducible bug report the SDK team can act on, and the gated test becomes the acceptance test for the fix.

+
+ + +
+

12 How to Run the Tests

+
# All UTS tests (unit + proxy). Proxy suites download/launch the proxy automatically.
+./gradlew :uts:test
+
+# Just the unit test class:
+./gradlew :uts:test --tests "io.ably.lib.realtime.unit.connection.ConnectionRecoveryTest"
+
+# Just the proxy test class (needs network: sandbox + GitHub for the proxy binary):
+./gradlew :uts:test --tests "io.ably.lib.realtime.integration.proxy.AuthReauthTest"
+
+# Turn on the spec-correct (currently failing) deviation assertions:
+RUN_DEVIATIONS=1 ./gradlew :uts:test --tests "*ConnectionRecoveryTest*"
+
+# Run proxy tests against a locally built proxy instead of a GitHub release:
+./gradlew :uts:test -Duts.proxy.localPath=/path/to/uts-proxy            # or .tar.gz
+#   (equivalently: export UTS_PROXY_LOCAL_PATH=/path/to/uts-proxy)
+

Notes:

+
    +
  • ProxyManager advises running proxy suites single-fork (maxParallelForks = 1) because they share the control port (10100). Not currently set in uts/build.gradle.kts; not yet exercised because there is only one proxy test class.
  • +
  • Proxy/sandbox tests need outbound network (sandbox + GitHub releases on first run; the binary is then cached under ~/.cache/uts-proxy/).
  • +
  • Before pushing, run the static-analysis gate (from CLAUDE.md): ./gradlew checkWithCodenarc checkstyleMain checkstyleTest — Checkstyle is Java-only and easy to miss; remember no star imports. (CI's full line additionally runs runUnitTests runLiveObjectsUnitTests :uts:test.)
  • +
+
+ + +
+

13 Quick Reference / Cheat-Sheet

+

The three seams that make unit tests possible (DebugOptions): webSocketEngineFactory httpEngine clock

+

Build a unit-test client

+
val mock = MockWebSocket { onConnectionAttempt = { it.respondWithSuccess(CONNECTED_MESSAGE) } }
+val client = TestRealtimeClient { autoConnect = false; install(mock) }
+client.connect(); awaitState(client, ConnectionState.connected)
+

Build a proxy-test client

+
ProxyManager.ensureProxy(); val app = SandboxApp.create()
+val session = ProxySession.create(rules = emptyList())
+val client = TestRealtimeClient { authCallback = …; connectThroughProxy(session); autoConnect = false }
+

Server→client (mock): sendToClient (stays open) · sendToClientAndClose (DISCONNECTED / fatal ERROR) · simulateDisconnect (1006 drop).

+

Inspect what the SDK did: mock.events (unit) · session.getLog() (proxy).

+

Wait (never sleep): awaitState · awaitChannelState · pollUntil { … } · FakeClock.advance(…).

+

Protocol action numbers: CONNECTED=4 DISCONNECTED=6 ERROR=9 ATTACH=10 ATTACHED=11 DETACH=12 DETACHED=13 AUTH=17

+

Test ID format: <category>/<spec-point>/<descriptive-name>-<n>@UTS realtime/proxy/RTN22/server-initiated-reauth-0

+

Decision tree on failure: spec wrong → fix test + record UTS spec error · translation wrong → fix test · SDK non-compliant → gate spec-correct assertion behind RUN_DEVIATIONS + record in deviations.md.

+
+ + +
+

A Appendix A: Request-Flow Diagrams

+ +

A.1 Unit test — mocked WebSocket (no network)

+

A unit test installs MockWebSocket into DebugOptions.webSocketEngineFactory. The SDK believes it's talking to a real socket; in fact every byte is intercepted by the mock and surfaced to the test.

+
+ + + TEST (Kotlin coroutine) — in-process, no TCP / DNS / real time + + + AblyRealtime + SDK (:java) + ConnectionManager + + + MockWebSocketEngineFactory + + + MockWebSocket + records MockEvent[] + onConnectionAttempt + onMessageFromClient + sendToClient / … + + + TEST drives both sides + respondWithSuccess(msg) + sendToClient(CONNECTED) + simulateDisconnect() + mock.events (assert) + + + factory + + send(frame) + + onMessage + + + + FakeClock.advance(2.s) + + + +
Everything is in-process and deterministic. The HTTP path is identical in shape: MockHttpClientDebugOptions.httpEngineMockHttpEnginePendingConnection then PendingRequest.
+
+ +

A.2 Proxy test — real backend through the fault-injecting proxy

+

A proxy test uses the real SDK transport but points its host/port at the local uts-proxy process, which forwards to the Ably sandbox and can inject faults on command.

+
+ + + + AblyRealtime + REAL transport + + + uts-proxy + forwards traffic + applies rules + records event log + + + Ably sandbox + sandbox.realtime. + ably-nonprod.net + TLS + + + + ws/http (plain, tls=false) + + + upstream TLS + + + TEST controls the proxy (CONTROL plane → :10100) + triggerAction({inject_to_client, action:17}) · addRules(...) · getLog() + + + SandboxApp.create() + POST /apps (direct, TLS) + + + + +
Two channels to the proxy: the data plane (SDK ws/http on session.proxyPort) is separate from the control plane (test REST on CONTROL_PORT=10100). The SDK never sees the control plane; the test never speaks the data plane directly. Everything before the injected fault is real client↔server traffic.
+
+
+ + +
+

B Appendix B: Per-File API Reference

+ +

B.1 Unit-test infrastructure — io.ably.lib.uts.infra

+ + + + + + + + + + + + + +
FileKey public surfaceRole
ClientFactories.ktClientOptionsBuilder (extends DebugOptions), TestRealtimeClient { }, TestRestClient { }, install(mock), enableFakeTimers(clock)Entry point for building a mocked SDK client; seeds dummy key, forces JSON.
MockWebSocket.ktMockWebSocket, WebSocketMockConfig (onConnectionAttempt, onMessageFromClient, onTextDataFrame, onBinaryDataFrame), events, installOn, awaitConnectionAttempt, awaitNextMessageFromClient, awaitClientClose, sendToClient, sendToClientAndClose, simulateDisconnect, reset; top-level MockWebSocket { }, CONNECTED_MESSAGEFake realtime transport (callback + await styles).
MockWebSocketEngineFactory.ktMockWebSocketEngineFactory, MockWebSocketEngine, MockWebSocketClient (implement WebSocketEngineFactory/Engine/Client)Adapts the mock to the SDK's WebSocket SPI; parses URL → host/port/tls/query.
MockHttpClient.ktMockHttpClient, HttpMockConfig (onConnectionAttempt, onRequest), engine, installOn, awaitConnectionAttempt, awaitRequest, reset; top-level MockHttpClient { }Fake REST transport.
MockHttpEngine.ktMockHttpEngine, MockHttpCall, DefaultHttpPendingConnection (implement HttpEngine/HttpCall)Adapts the mock to the SDK's HTTP SPI; two-phase connect→request in execute().
PendingConnection.ktinterface PendingConnection (host,port,tls,queryParams, respondWithSuccess[ (message) ], respondWithRefused/Timeout/DnsError); plus top-level helper parseQueryString() (not an interface member)Abstract connection attempt awaiting a verdict (shared WS + HTTP).
DefaultPendingConnection.ktDefaultPendingConnection : PendingConnectionWS impl; async CONNECTED delivery on mock-ws-delivery thread.
PendingRequest.ktinterface PendingRequest (url,method,headers,body, respondWith, respondWithDelay, respondWithTimeout)Abstract in-flight HTTP request awaiting a response.
DefaultPendingRequest.ktDefaultPendingRequest : PendingRequestHTTP impl backed by a CompletableDeferred<HttpResponse>.
MockEvent.ktsealed class MockEvent: ConnectionAttempt, ConnectionEstablished, ConnectionRefused, ConnectionTimeout, DnsError, HttpRequest, SentToClient, Disconnected, ClientClose, MessageFromClientOrdered, typed log of everything on a mock transport.
FakeClock.ktFakeClock : Clock (advance(ms|Duration), pendingTaskCount(name), currentTimeMillis, nanoTime, newTimer, waitOn)Virtual clock + virtual timers; deterministic time.
+ +

B.2 Proxy/sandbox infrastructure — io.ably.lib.test.helper

+ + + + + +
FileKey public surfaceRole
ProxyManager.ktobject ProxyManager: ensureProxy(timeoutMs), stopProxy(), CONTROL_PORT=10100, sandboxRealtimeHost, sandboxRestHost; pinned PROXY_VERSION=v0.3.0 + per-arch checksums; uts.proxy.localPath overrideDownloads/verifies/launches the uts-proxy binary; one shared process per run.
ProxySession.ktclass ProxySession (create(rules,port,timeoutMs,realtimeHost,restHost), addRules, triggerAction, getLog(): List<Event>, close, sessionId, proxyPort, proxyHost); data class Event; typealias ProxyRule; rule builders wsConnectRule/wsFrameToClientRule/wsFrameToServerRule/httpRequestRule; ClientOptionsBuilder.connectThroughProxy(session)Typed client for the proxy control REST API + client wiring.
SandboxApp.ktclass SandboxApp (create(), delete(), appId, defaultKey, keys)Provisions/tears down a throwaway sandbox app from ably-common's test-app-setup.json.
+ +

B.3 Shared helpers & tests

+ + + + + + + +
FileKey public surfaceRole
io/ably/lib/Utils.ktawaitState(client,target,timeout=5s), awaitChannelState(channel,target,timeout=5s), pollUntil(timeout=15s,interval=100ms){ }Wall-clock coroutine waits; listener registered before state check.
io/ably/lib/types/Utils.ktConnectionDetails { } builderDSL sugar for building ConnectionDetails in tests.
realtime/unit/connection/ConnectionRecoveryTest.kt6 @Tests: RTN16g/g1, RTN16g2, RTN16k, RTN16f, RTN16f1, RTN16jUnit tier — connection recovery (mocked WS, FakeClock, env-gated deviations).
realtime/integration/proxy/AuthReauthTest.kt1 @Test (two @UTS: RTN22, RTC8a)Proxy tier — server-initiated re-authentication.
deviations.mdRTN16f, RTN16g2, RTL13b, RTL13cCatalogue of SDK-vs-spec divergences.
+
Coverage note

At the time of writing, the uts/ module contains exactly two test classes (7 @Test methods total: 6 in ConnectionRecoveryTest + 1 in AuthReauthTest). The infrastructure under uts/infra/ and test/helper/ is built out far beyond what these two tests exercise (full HTTP mock, all four rule builders, REST proxy wiring), anticipating the broader UTS coverage catalogued in completion-status.md.

+
+ +
+

Source map — where each fact comes from

+ + + + + + + + + + + + + + +
TopicFile
Authoring portable specs, test IDs, mock pseudocodeuts/docs/writing-test-specs.md
Translating specs, deviation patterns, decision treeuts/docs/writing-derived-tests.md
Integration/proxy policy, late fault injection, tiersuts/docs/integration-testing.md
Coverage matrixuts/docs/completion-status.md
Proxy control API, rule format, action numbersuts/realtime/integration/helpers/proxy.md
SDK seamslib/.../debug/DebugOptions.java, lib/.../util/Clock.java
Module wiringuts/build.gradle.kts, settings.gradle.kts
Unit mocksuts/.../uts/infra/*
Proxy/sandbox helpersuts/.../test/helper/*
Async helpersuts/.../io/ably/lib/Utils.kt, …/types/Utils.kt
The two example tests…/unit/connection/ConnectionRecoveryTest.kt, …/integration/proxy/AuthReauthTest.kt
Deviationsuts/.../io/ably/lib/deviations.md
+

Generated from UTS_HUMAN_READABLE_DOC.md. Single self-contained HTML file — no external assets.

+
+
+
+ + + + From f98d4641df82be50d41de70b86ca06a2a6bf5815 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 25 Jun 2026 15:23:25 +0530 Subject: [PATCH 30/40] docs(uts): add UTS guide (README.md) and standalone website (index.html) Move the human-readable UTS guide and its self-contained HTML rendering into the uts/ module as README.md and index.html. Both cover the UTS concept, the three test tiers, the spec docs, the uts/ module layout, mock/proxy infrastructure, the two example tests, deviations, and appendices. Spec-doc references link to GitHub; paths are fully qualified; the two artifacts are kept in sync. Co-Authored-By: Claude Opus 4.8 (1M context) --- UTS_HUMAN_READABLE_DOC.md => uts/README.md | 43 ++++++++++++---------- website.html => uts/index.html | 42 ++++++++++----------- 2 files changed, 44 insertions(+), 41 deletions(-) rename UTS_HUMAN_READABLE_DOC.md => uts/README.md (94%) rename website.html => uts/index.html (93%) diff --git a/UTS_HUMAN_READABLE_DOC.md b/uts/README.md similarity index 94% rename from UTS_HUMAN_READABLE_DOC.md rename to uts/README.md index a322d55a2..5666554a7 100644 --- a/UTS_HUMAN_READABLE_DOC.md +++ b/uts/README.md @@ -66,7 +66,7 @@ Three concepts you will see constantly: | **Derived test** | A faithful translation of a UTS spec into a real test in a specific SDK/language. This is what lives in `ably-java/uts/`. | | **Deviation** | A documented case where the SDK's actual behaviour diverges from the spec. Recorded in `deviations.md`. | -The golden rule (from `writing-derived-tests.md`): **translate the UTS spec faithfully** — same +The golden rule (from [`writing-derived-tests.md`](https://github.com/ably/specification/blob/main/uts/docs/writing-derived-tests.md)): **translate the UTS spec faithfully** — same structure, same assertions, same naming — don't optimise or skip steps. Every derived test carries a `// UTS: ` (here `@UTS …`) comment linking it back to its spec. @@ -84,7 +84,7 @@ tests you asked about sit in two different tiers. | **Direct sandbox integration** | Real network | Real Ably sandbox | Happy-path interop: connect, publish, subscribe. No fault injection. | *(not in the two you asked about)* | | **Proxy integration** | Real network **through a programmable proxy** | Real Ably sandbox | Fault behaviour: dropped connections, injected errors, timeouts, re-auth. | `integration/proxy/AuthReauthTest.kt` | -Key principles (from `integration-testing.md`): +Key principles (from [`integration-testing.md`](https://github.com/ably/specification/blob/main/uts/docs/integration-testing.md)): - **Integration tests do not replace unit tests.** A spec point covered by a proxy test should *also* have a unit test. The unit test proves the client logic; the proxy test proves the client @@ -94,18 +94,20 @@ Key principles (from `integration-testing.md`): exercises genuine client-server behaviour (otherwise you've just written a slow unit test). - **Proxy tests always use JSON** (`useBinaryProtocol = false`). The spec corpus gives two reasons: the proxy only supports **text** WebSocket frames so it can't inspect/modify msgpack - (`integration-testing.md` §Protocol Variants), and the SDK under test doesn't implement msgpack - (`helpers/proxy.md`). + ([`integration-testing.md`](https://github.com/ably/specification/blob/main/uts/docs/integration-testing.md) §Protocol Variants), and the SDK under test doesn't implement msgpack + ([`helpers/proxy.md`](https://github.com/ably/specification/blob/main/uts/realtime/integration/helpers/proxy.md)). --- ## 3. The UTS Documents (the source of truth) These four documents live in the **specification repo** at -`/Users/sachinsh/ably-specification/specification/uts/docs/`. They are the policy/authoring guides; -the Kotlin code in this repo is the *implementation* of what they describe. +[`uts/docs/`](https://github.com/ably/specification/blob/main/uts/docs/) (in a local +`ably-specification` checkout, under `specification/uts/docs/`). They are the policy/authoring guides; +the Kotlin code in this repo is the *implementation* of what they describe. Each title below links to +the file on GitHub. -### 3.1 `writing-test-specs.md` — how to author a portable UTS spec +### 3.1 [`writing-test-specs.md`](https://github.com/ably/specification/blob/main/uts/docs/writing-test-specs.md) — how to author a portable UTS spec The authoring manual. Defines: - **Test types** (unit / integration / proxy) and when each applies. - **Test IDs** — the format `//-`, e.g. @@ -122,7 +124,7 @@ The authoring manual. Defines: - **Anti-flake conventions** — no fixed `WAIT`s; use polling, `AWAIT_STATE`, fake timers, and the **record-and-verify** pattern (`CONTAINS_IN_ORDER`) for transient states. -### 3.2 `writing-derived-tests.md` — how to translate a spec into a real SDK test +### 3.2 [`writing-derived-tests.md`](https://github.com/ably/specification/blob/main/uts/docs/writing-derived-tests.md) — how to translate a spec into a real SDK test The translation manual. Two phases: 1. **Translation** (always): faithfully render the spec into the target language; map pseudocode to the SDK's API and test framework; flag ambiguities in comments; make sure it compiles. @@ -136,13 +138,13 @@ The translation manual. Two phases: assertion but only runs it when the env var is set, so normal runs stay green while each deviation stays individually reproducible. This is exactly what `ConnectionRecoveryTest` uses for RTN16f. -### 3.3 `integration-testing.md` — the policy for integration & proxy tests +### 3.3 [`integration-testing.md`](https://github.com/ably/specification/blob/main/uts/docs/integration-testing.md) — the policy for integration & proxy tests Defines what *deserves* an integration test (request/response interop, error interop, data round-trips, stateful protocol sequences), the directory layout, sandbox provisioning, proxy session lifecycle, timeout strategy, and the **late-fault-injection** philosophy. The `integration/proxy/` segregation exists because proxy tests have different infra needs, CI cadence, and failure modes. -### 3.4 `completion-status.md` — the coverage matrix +### 3.4 [`completion-status.md`](https://github.com/ably/specification/blob/main/uts/docs/completion-status.md) — the coverage matrix A big table mapping every features-spec group (`RSC`, `RTN`, `RTL`, `RTP`, …) to the UTS specs that cover it, with a per-tier summary (`unit:✓ proxy:✓`). This is the tracker for "what's done and what's missing". The two tests you asked about correspond to these rows: @@ -150,8 +152,9 @@ what's missing". The two tests you asked about correspond to these rows: - `RTN22` / `RTC8a` (server-initiated re-auth) → proxy spec `realtime/integration/proxy/auth_reauth.md` → **`AuthReauthTest.kt`**. -> There is also a fifth, *referenced* spec: `realtime/integration/helpers/proxy.md` (in the spec repo -> under `uts/realtime/integration/helpers/`). It defines the proxy's control API, rule format, +> There is also a fifth, *referenced* spec: +> [`realtime/integration/helpers/proxy.md`](https://github.com/ably/specification/blob/main/uts/realtime/integration/helpers/proxy.md) +> (in the spec repo under `uts/realtime/integration/helpers/`). It defines the proxy's control API, rule format, > action types, and the **protocol message action-number table** (CONNECTED=4, ATTACH=10, AUTH=17, > …). The Kotlin `ProxySession` is the client for exactly that API. @@ -161,7 +164,7 @@ what's missing". The two tests you asked about correspond to these rows: The `uts/` directory is a **standalone Gradle module** (`include("uts")` in `settings.gradle.kts`) whose only job is to host UTS-derived tests. It contains *no production code* — -everything lives under `src/test/`. +everything lives under `uts/src/test/`. ### 4.1 `uts/build.gradle.kts` ```kotlin @@ -616,7 +619,7 @@ differently from the features spec, discovered during translation. Each entry re point**, **what the spec requires**, **what the SDK does**, the **root cause** (file/function, where known), the **workaround in tests**, and the **affected tests**. -The mechanism (from `writing-derived-tests.md`): the test keeps the **spec-correct** assertion but +The mechanism (from [`writing-derived-tests.md`](https://github.com/ably/specification/blob/main/uts/docs/writing-derived-tests.md)): the test keeps the **spec-correct** assertion but gates it behind the `RUN_DEVIATIONS` env var, with a regression-guard assertion for the SDK's actual behaviour running by default. Normal runs stay green; `RUN_DEVIATIONS=1` turns the failing assertions on so the gap is reproducible and the test flips automatically once the SDK is fixed. @@ -823,7 +826,7 @@ nothing is left implicit. > (**7** `@Test` methods total: 6 in `ConnectionRecoveryTest` + 1 in `AuthReauthTest`). The infrastructure under > `uts/infra/` and `test/helper/` is built out far beyond what these two tests exercise (full HTTP > mock, all four rule builders, REST proxy wiring, etc.), anticipating the broader UTS coverage -> catalogued in `completion-status.md`. +> catalogued in [`completion-status.md`](https://github.com/ably/specification/blob/main/uts/docs/completion-status.md). --- @@ -831,11 +834,11 @@ nothing is left implicit. | Topic | File | |-------|------| -| Authoring portable specs, test IDs, mock pseudocode | `ably-specification/.../uts/docs/writing-test-specs.md` | -| Translating specs, deviation patterns, decision tree | `…/uts/docs/writing-derived-tests.md` | -| Integration/proxy policy, late fault injection, tiers | `…/uts/docs/integration-testing.md` | -| Coverage matrix | `…/uts/docs/completion-status.md` | -| Proxy control API, rule format, action numbers | `…/uts/realtime/integration/helpers/proxy.md` | +| Authoring portable specs, test IDs, mock pseudocode | [`uts/docs/writing-test-specs.md`](https://github.com/ably/specification/blob/main/uts/docs/writing-test-specs.md) | +| Translating specs, deviation patterns, decision tree | [`uts/docs/writing-derived-tests.md`](https://github.com/ably/specification/blob/main/uts/docs/writing-derived-tests.md) | +| Integration/proxy policy, late fault injection, tiers | [`uts/docs/integration-testing.md`](https://github.com/ably/specification/blob/main/uts/docs/integration-testing.md) | +| Coverage matrix | [`uts/docs/completion-status.md`](https://github.com/ably/specification/blob/main/uts/docs/completion-status.md) | +| Proxy control API, rule format, action numbers | [`uts/realtime/integration/helpers/proxy.md`](https://github.com/ably/specification/blob/main/uts/realtime/integration/helpers/proxy.md) | | SDK seams | `lib/.../debug/DebugOptions.java`, `lib/.../util/Clock.java` | | Module wiring | `uts/build.gradle.kts`, `settings.gradle.kts` | | Unit mocks | `uts/.../uts/infra/*` | diff --git a/website.html b/uts/index.html similarity index 93% rename from website.html rename to uts/index.html index 8b788b12a..739829046 100644 --- a/website.html +++ b/uts/index.html @@ -182,7 +182,7 @@

1 Introduction: What is UTS?

DeviationA documented case where the SDK's actual behaviour diverges from the spec. Recorded in deviations.md.
Golden rule -

From writing-derived-tests.md: translate the UTS spec faithfully — same structure, same assertions, same naming — don't optimise or skip steps. Every derived test carries a // UTS: <id> (here @UTS …) comment linking it back to its spec.

+

From writing-derived-tests.md: translate the UTS spec faithfully — same structure, same assertions, same naming — don't optimise or skip steps. Every derived test carries a // UTS: <id> (here @UTS …) comment linking it back to its spec.

@@ -194,20 +194,20 @@

2 The Three Test Tiers

Direct sandbox real net

Transport: real. Backend: real Ably sandbox.
Happy-path interop: connect, publish, subscribe. No fault injection.
(not among the two example tests)

Proxy integration faults

Transport: real, through a programmable proxy. Backend: real sandbox.
Fault behaviour: dropped connections, injected errors, timeouts, re-auth.
→ AuthReauthTest.kt

-

Key principles (from integration-testing.md):

+

Key principles (from integration-testing.md):

  • Integration tests do not replace unit tests. A spec point covered by a proxy test should also have a unit test. The unit test proves client logic; the proxy test proves client & real server agree.
  • Proxy tests prefer "late fault injection". Let the real handshake complete against the real server, then inject the fault as the final interaction — maximising how much of the test exercises genuine client-server behaviour.
  • -
  • Proxy tests always use JSON (useBinaryProtocol = false). Two reasons in the spec corpus: the proxy only supports text WebSocket frames so it can't inspect/modify msgpack (integration-testing.md), and the SDK under test doesn't implement msgpack (helpers/proxy.md).
  • +
  • Proxy tests always use JSON (useBinaryProtocol = false). Two reasons in the spec corpus: the proxy only supports text WebSocket frames so it can't inspect/modify msgpack (integration-testing.md), and the SDK under test doesn't implement msgpack (helpers/proxy.md).

3 The UTS Documents (the source of truth)

-

These four documents live in the specification repo at specification/uts/docs/. They are the policy/authoring guides; the Kotlin code in this repo is their implementation.

+

These four documents live in the specification repo at uts/docs/. They are the policy/authoring guides; the Kotlin code in this repo is their implementation. Each title below links to the file on GitHub. ↗

-

3.1 writing-test-specs.md — how to author a portable UTS spec

+

3.1 writing-test-specs.md — how to author a portable UTS spec

  • Test types (unit / integration / proxy) and when each applies.
  • Test IDs — format <category>/<spec-point>/<descriptive-name>-<n>, e.g. realtime/proxy/RTN22/server-initiated-reauth-0. These IDs are the @UTS comments in the Kotlin tests.
  • @@ -217,7 +217,7 @@

    3.1 writing-test-specs.md — how to author a portable UTS spec
  • Anti-flake conventions — no fixed WAITs; use polling, AWAIT_STATE, fake timers, and the record-and-verify pattern (CONTAINS_IN_ORDER) for transient states.
-

3.2 writing-derived-tests.md — how to translate a spec into a real SDK test

+

3.2 writing-derived-tests.md — how to translate a spec into a real SDK test

Two phases: Translation (always) — faithfully render the spec into the target language, map pseudocode to the SDK's API & test framework, flag ambiguities, ensure it compiles. Evaluation (when an implementation exists) — run the test and, if it fails, work the decision tree:

@@ -237,27 +237,27 @@

3.2 writing-derived-tests.md — how to translate a spec into a -
The three-branch decision tree from writing-derived-tests.md.
+
The three-branch decision tree from writing-derived-tests.md.

It also defines the env-gated skip pattern (RUN_DEVIATIONS) — the test holds the spec-correct assertion but only runs it when the env var is set, so normal runs stay green while each deviation stays individually reproducible. This is exactly what ConnectionRecoveryTest uses for RTN16f.

-

3.3 integration-testing.md — the policy for integration & proxy tests

+

3.3 integration-testing.md — the policy for integration & proxy tests

Defines what deserves an integration test (request/response interop, error interop, data round-trips, stateful protocol sequences), the directory layout, sandbox provisioning, proxy session lifecycle, timeout strategy, and the late-fault-injection philosophy. The integration/proxy/ segregation exists because proxy tests have different infra needs, CI cadence, and failure modes.

-

3.4 completion-status.md — the coverage matrix

+

3.4 completion-status.md — the coverage matrix

A big table mapping every features-spec group (RSC, RTN, RTL, RTP…) to the UTS specs that cover it, with a per-tier summary (unit:✓ proxy:✓). The two example tests correspond to these rows:

  • RTN16 (connection recovery) → unit spec connection_recovery_test.mdConnectionRecoveryTest.kt.
  • RTN22 / RTC8a (server-initiated re-auth) → proxy spec realtime/integration/proxy/auth_reauth.mdAuthReauthTest.kt.
Fifth, referenced doc -

There is also realtime/integration/helpers/proxy.md. It defines the proxy's control API, rule format, action types, and the protocol message action-number table (CONNECTED=4, ATTACH=10, AUTH=17…). The Kotlin ProxySession is the client for exactly that API.

+

There is also realtime/integration/helpers/proxy.md. It defines the proxy's control API, rule format, action types, and the protocol message action-number table (CONNECTED=4, ATTACH=10, AUTH=17…). The Kotlin ProxySession is the client for exactly that API.

4 The Java Setup: the uts/ module

-

The uts/ directory is a standalone Gradle module (include("uts") in settings.gradle.kts) whose only job is to host UTS-derived tests. It contains no production code — everything lives under src/test/.

+

The uts/ directory is a standalone Gradle module (include("uts") in settings.gradle.kts) whose only job is to host UTS-derived tests. It contains no production code — everything lives under uts/src/test/.

4.1 uts/build.gradle.kts

plugins { alias(libs.plugins.kotlin.jvm) }
@@ -417,7 +417,7 @@ 

6.4 FakeClock — deterministic time

-

7 Proxy-Integration Infrastructure (real backend + faults)

+

7 Proxy-Integration Infrastructure (real backend + fault injection)

Proxy tests connect the real SDK to the real Ably sandbox, but route traffic through a small Go program — ably/uts-proxy — that can be told to inject faults. Three Kotlin helpers make this work.

7.1 ProxyManager — gets the proxy binary running

@@ -487,7 +487,7 @@

9.2 RTN16g2createRecoveryKey() returns null i
  • INITIALIZED (before connect) → null.
  • CONNECTED → non-null (sanity).
  • CLOSING / CLOSED → null (close nulls the key immediately).
  • -
  • FAILED → null. (Documented deviation — see §11.)
  • +
  • FAILED → null. (Contains a documented deviation — see §11: the spec's fatal error code 50000/500 isn't treated as fatal by the SDK, and send_to_client_and_close races the FAILED transition; the test uses code 40000/400 and plain sendToClient.)
  • SUSPENDED → null. Built with a FakeClock: connect succeeds, then simulateDisconnect(), then a coroutine refuses every reconnection attempt while fakeClock.advance(2.seconds) loops until the short connectionStateTtl (800 ms) expires and the client gives up to SUSPENDED.
  • Technique: the textbook await-style example — the first connection succeeds via awaitConnectionAttempt(), but reconnections need the refused response, so a separate refuseJob coroutine drives them; combined with fake timers this gives a deterministic SUSPENDED.

    @@ -666,7 +666,7 @@

    A.1 Unit test — mocked WebSocket (no network)

    Everything is in-process and deterministic. The HTTP path is identical in shape: MockHttpClientDebugOptions.httpEngineMockHttpEnginePendingConnection then PendingRequest.
    -

    A.2 Proxy test — real backend through the fault-injecting proxy

    +

    A.2 Proxy integration test — real backend through the fault-injecting proxy

    A proxy test uses the real SDK transport but points its host/port at the local uts-proxy process, which forwards to the Ably sandbox and can inject faults on command.

    @@ -746,18 +746,18 @@

    B.3 Shared helpers & tests

    realtime/integration/proxy/AuthReauthTest.kt1 @Test (two @UTS: RTN22, RTC8a)Proxy tier — server-initiated re-authentication. deviations.mdRTN16f, RTN16g2, RTL13b, RTL13cCatalogue of SDK-vs-spec divergences. -
    Coverage note

    At the time of writing, the uts/ module contains exactly two test classes (7 @Test methods total: 6 in ConnectionRecoveryTest + 1 in AuthReauthTest). The infrastructure under uts/infra/ and test/helper/ is built out far beyond what these two tests exercise (full HTTP mock, all four rule builders, REST proxy wiring), anticipating the broader UTS coverage catalogued in completion-status.md.

    +
    Coverage note

    At the time of writing, the uts/ module contains exactly two test classes (7 @Test methods total: 6 in ConnectionRecoveryTest + 1 in AuthReauthTest). The infrastructure under uts/infra/ and test/helper/ is built out far beyond what these two tests exercise (full HTTP mock, all four rule builders, REST proxy wiring), anticipating the broader UTS coverage catalogued in completion-status.md.

    Source map — where each fact comes from

    - - - - - + + + + + @@ -766,7 +766,7 @@

    Source map — where each fact comes from

    TopicFile
    Authoring portable specs, test IDs, mock pseudocodeuts/docs/writing-test-specs.md
    Translating specs, deviation patterns, decision treeuts/docs/writing-derived-tests.md
    Integration/proxy policy, late fault injection, tiersuts/docs/integration-testing.md
    Coverage matrixuts/docs/completion-status.md
    Proxy control API, rule format, action numbersuts/realtime/integration/helpers/proxy.md
    Authoring portable specs, test IDs, mock pseudocodeuts/docs/writing-test-specs.md
    Translating specs, deviation patterns, decision treeuts/docs/writing-derived-tests.md
    Integration/proxy policy, late fault injection, tiersuts/docs/integration-testing.md
    Coverage matrixuts/docs/completion-status.md
    Proxy control API, rule format, action numbersuts/realtime/integration/helpers/proxy.md
    SDK seamslib/.../debug/DebugOptions.java, lib/.../util/Clock.java
    Module wiringuts/build.gradle.kts, settings.gradle.kts
    Unit mocksuts/.../uts/infra/*
    The two example tests…/unit/connection/ConnectionRecoveryTest.kt, …/integration/proxy/AuthReauthTest.kt
    Deviationsuts/.../io/ably/lib/deviations.md
    -

    Generated from UTS_HUMAN_READABLE_DOC.md. Single self-contained HTML file — no external assets.

    +

    Generated from README.md (in this uts/ directory). Single self-contained HTML file — no external assets.

    From aa0504e60dec6b5ea795906d22c06b6c7e1a6360 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 25 Jun 2026 16:37:47 +0530 Subject: [PATCH 31/40] refactor(uts): split test sources into unit/integration tiers + per-tier Gradle tasks Reorganise the uts module under a domain-rooted io.ably.lib.uts package that cleanly separates infrastructure from tests, and unit from integration: infra/ shared awaits (Utils.kt) infra/unit/ mock transports + ConnectionDetails builder infra/integration/ SandboxApp infra/integration/proxy/ ProxyManager, ProxySession unit/realtime/ ConnectionRecoveryTest (mocked) integration/proxy/realtime/ AuthReauthTest (sandbox + proxy) - The ConnectionDetails test builder no longer sits in io.ably.lib.types, so it obtains the package-private constructor reflectively (as liveobjects/TestUtils.kt does). - Add runUtsUnitTests / runUtsIntegrationTests Gradle tasks (filtered by package), mirroring runLiveObjectsUnitTests / runLiveObjectsIntegrationTests. - check.yml now runs :uts:runUtsUnitTests; integration-test.yml gains a check-uts job running :uts:runUtsIntegrationTests. - Bring uts/README.md and uts/index.html in sync with the new structure. --- .github/workflows/check.yml | 2 +- .github/workflows/integration-test.yml | 21 +++ uts/README.md | 145 +++++++++++------- uts/build.gradle.kts | 12 ++ uts/index.html | 121 ++++++++------- .../test/kotlin/io/ably/lib/types/Utils.kt | 3 - .../io/ably/lib/{ => uts}/deviations.md | 0 .../io/ably/lib/{ => uts/infra}/Utils.kt | 2 +- .../infra/integration}/SandboxApp.kt | 3 +- .../infra/integration/proxy}/ProxyManager.kt | 2 +- .../infra/integration/proxy}/ProxySession.kt | 4 +- .../uts/infra/{ => unit}/ClientFactories.kt | 2 +- .../{ => unit}/DefaultPendingConnection.kt | 2 +- .../infra/{ => unit}/DefaultPendingRequest.kt | 2 +- .../lib/uts/infra/{ => unit}/FakeClock.kt | 2 +- .../lib/uts/infra/{ => unit}/MockEvent.kt | 2 +- .../uts/infra/{ => unit}/MockHttpClient.kt | 2 +- .../uts/infra/{ => unit}/MockHttpEngine.kt | 4 +- .../lib/uts/infra/{ => unit}/MockWebSocket.kt | 3 +- .../{ => unit}/MockWebSocketEngineFactory.kt | 2 +- .../uts/infra/{ => unit}/PendingConnection.kt | 2 +- .../uts/infra/{ => unit}/PendingRequest.kt | 2 +- .../io/ably/lib/uts/infra/unit/Utils.kt | 20 +++ .../proxy/realtime}/AuthReauthTest.kt | 16 +- .../unit/realtime}/ConnectionRecoveryTest.kt | 19 +-- 25 files changed, 243 insertions(+), 152 deletions(-) delete mode 100644 uts/src/test/kotlin/io/ably/lib/types/Utils.kt rename uts/src/test/kotlin/io/ably/lib/{ => uts}/deviations.md (100%) rename uts/src/test/kotlin/io/ably/lib/{ => uts/infra}/Utils.kt (98%) rename uts/src/test/kotlin/io/ably/lib/{test/helper => uts/infra/integration}/SandboxApp.kt (97%) rename uts/src/test/kotlin/io/ably/lib/{test/helper => uts/infra/integration/proxy}/ProxyManager.kt (99%) rename uts/src/test/kotlin/io/ably/lib/{test/helper => uts/infra/integration/proxy}/ProxySession.kt (99%) rename uts/src/test/kotlin/io/ably/lib/uts/infra/{ => unit}/ClientFactories.kt (95%) rename uts/src/test/kotlin/io/ably/lib/uts/infra/{ => unit}/DefaultPendingConnection.kt (97%) rename uts/src/test/kotlin/io/ably/lib/uts/infra/{ => unit}/DefaultPendingRequest.kt (97%) rename uts/src/test/kotlin/io/ably/lib/uts/infra/{ => unit}/FakeClock.kt (98%) rename uts/src/test/kotlin/io/ably/lib/uts/infra/{ => unit}/MockEvent.kt (97%) rename uts/src/test/kotlin/io/ably/lib/uts/infra/{ => unit}/MockHttpClient.kt (98%) rename uts/src/test/kotlin/io/ably/lib/uts/infra/{ => unit}/MockHttpEngine.kt (95%) rename uts/src/test/kotlin/io/ably/lib/uts/infra/{ => unit}/MockWebSocket.kt (99%) rename uts/src/test/kotlin/io/ably/lib/uts/infra/{ => unit}/MockWebSocketEngineFactory.kt (98%) rename uts/src/test/kotlin/io/ably/lib/uts/infra/{ => unit}/PendingConnection.kt (97%) rename uts/src/test/kotlin/io/ably/lib/uts/infra/{ => unit}/PendingRequest.kt (95%) create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/infra/unit/Utils.kt rename uts/src/test/kotlin/io/ably/lib/{realtime/integration/proxy => uts/integration/proxy/realtime}/AuthReauthTest.kt (92%) rename uts/src/test/kotlin/io/ably/lib/{realtime/unit/connection => uts/unit/realtime}/ConnectionRecoveryTest.kt (95%) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index c60db1d41..a2fe3a2bb 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -23,4 +23,4 @@ jobs: distribution: 'temurin' - name: Set up Gradle uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 - - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectsUnitTests :uts:test + - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectsUnitTests :uts:runUtsUnitTests diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 8250c30aa..df5e399a8 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -123,3 +123,24 @@ jobs: uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 - run: ./gradlew runLiveObjectsIntegrationTests + + check-uts: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: 'recursive' + persist-credentials: false + + - name: Set up the JDK + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 + + - run: ./gradlew :uts:runUtsIntegrationTests diff --git a/uts/README.md b/uts/README.md index 5666554a7..952f35c67 100644 --- a/uts/README.md +++ b/uts/README.md @@ -80,9 +80,9 @@ tests you asked about sit in two different tiers. | Tier | Transport | Backend | Purpose | Example in this repo | |------|-----------|---------|---------|----------------------| -| **Unit** | **Mocked** (`MockWebSocket`, `MockHttpClient`) | none | Client-side logic: state machines, request formation, response parsing, timer behaviour. Fast & deterministic. | `unit/connection/ConnectionRecoveryTest.kt` | +| **Unit** | **Mocked** (`MockWebSocket`, `MockHttpClient`) | none | Client-side logic: state machines, request formation, response parsing, timer behaviour. Fast & deterministic. | `unit/realtime/ConnectionRecoveryTest.kt` | | **Direct sandbox integration** | Real network | Real Ably sandbox | Happy-path interop: connect, publish, subscribe. No fault injection. | *(not in the two you asked about)* | -| **Proxy integration** | Real network **through a programmable proxy** | Real Ably sandbox | Fault behaviour: dropped connections, injected errors, timeouts, re-auth. | `integration/proxy/AuthReauthTest.kt` | +| **Proxy integration** | Real network **through a programmable proxy** | Real Ably sandbox | Fault behaviour: dropped connections, injected errors, timeouts, re-auth. | `integration/proxy/realtime/AuthReauthTest.kt` | Key principles (from [`integration-testing.md`](https://github.com/ably/specification/blob/main/uts/docs/integration-testing.md)): @@ -115,8 +115,8 @@ The authoring manual. Defines: comments in the Kotlin tests. - **Mock infrastructure pseudocode interfaces** — `MockHttpClient`, `MockWebSocket`, `PendingConnection`, `PendingRequest`, with `respond_with_success()`, `send_to_client()`, - `simulate_disconnect()`, etc. The Kotlin classes in `uts/infra/` are direct realisations of these - interfaces. + `simulate_disconnect()`, etc. The Kotlin classes in `uts/infra/unit/` are direct realisations of + these interfaces. - **Handler vs await patterns** for mocks (see §6). - **WebSocket closing semantics** — the crucial rule: `send_to_client_and_close()` for DISCONNECTED / connection-level ERROR (server closes the socket); `send_to_client()` for a @@ -201,39 +201,52 @@ Takeaways: against an unreleased proxy). ### 4.2 Directory layout + +Everything lives under the `io.ably.lib.uts` package, split cleanly into **infrastructure** (`infra/`, +no `@Test`s) and the **tests** themselves (`unit/`, `integration/`), each mirroring the unit / +integration tiers: + ``` -uts/src/test/kotlin/io/ably/lib/ -├── Utils.kt # awaitState / awaitChannelState / pollUntil (coroutine helpers) -├── types/Utils.kt # ConnectionDetails { … } builder DSL -├── deviations.md # the catalogue of SDK-vs-spec divergences +uts/src/test/kotlin/io/ably/lib/uts/ +├── deviations.md # the catalogue of SDK-vs-spec divergences │ -├── uts/infra/ # ── UNIT-TEST INFRASTRUCTURE (mocked transports) ── -│ ├── ClientFactories.kt # TestRealtimeClient / TestRestClient / ClientOptionsBuilder -│ ├── MockWebSocket.kt # fake WS transport + WebSocketMockConfig + CONNECTED_MESSAGE -│ ├── MockWebSocketEngineFactory.kt# plugs the mock into the SDK's WebSocketEngine SPI -│ ├── MockHttpClient.kt # fake HTTP engine + HttpMockConfig -│ ├── MockHttpEngine.kt # plugs the mock into the SDK's HttpEngine SPI -│ ├── MockEvent.kt # sealed log of everything that happened on a mock transport -│ ├── PendingConnection.kt # interface: a connection attempt awaiting a response -│ ├── DefaultPendingConnection.kt # WS implementation of PendingConnection -│ ├── PendingRequest.kt # interface: an in-flight HTTP request awaiting a response -│ ├── DefaultPendingRequest.kt # HTTP implementation of PendingRequest -│ └── FakeClock.kt # virtual clock + virtual timers (deterministic time) +├── infra/ # ── TEST INFRASTRUCTURE (no @Test methods) ── +│ ├── Utils.kt # awaitState / awaitChannelState / pollUntil (shared) +│ │ +│ ├── unit/ # UNIT infra (mocked transports) +│ │ ├── ClientFactories.kt # TestRealtimeClient / TestRestClient / ClientOptionsBuilder +│ │ ├── MockWebSocket.kt # fake WS transport + WebSocketMockConfig + CONNECTED_MESSAGE +│ │ ├── MockWebSocketEngineFactory.kt# plugs the mock into the SDK's WebSocketEngine SPI +│ │ ├── MockHttpClient.kt # fake HTTP engine + HttpMockConfig +│ │ ├── MockHttpEngine.kt # plugs the mock into the SDK's HttpEngine SPI +│ │ ├── MockEvent.kt # sealed log of everything on a mock transport +│ │ ├── PendingConnection.kt # interface: a connection attempt awaiting a response +│ │ ├── DefaultPendingConnection.kt # WS implementation of PendingConnection +│ │ ├── PendingRequest.kt # interface: an in-flight HTTP request awaiting a response +│ │ ├── DefaultPendingRequest.kt # HTTP implementation of PendingRequest +│ │ ├── FakeClock.kt # virtual clock + virtual timers (deterministic time) +│ │ └── Utils.kt # ConnectionDetails { } builder (reflective constructor) +│ │ +│ └── integration/ # INTEGRATION infra (real backend) +│ ├── SandboxApp.kt # provisions/deletes a sandbox app +│ └── proxy/ +│ ├── ProxyManager.kt # downloads/launches the uts-proxy binary +│ └── ProxySession.kt # proxy session: rules, actions, log + connectThroughProxy │ -├── test/helper/ # ── PROXY-INTEGRATION INFRASTRUCTURE (real backend) ── -│ ├── ProxyManager.kt # downloads/launches the uts-proxy binary -│ ├── ProxySession.kt # one proxy session: rules, actions, event log + connectThroughProxy -│ └── SandboxApp.kt # provisions/deletes a sandbox app +├── unit/ # ── UNIT TESTS (mock transport) ── +│ └── realtime/ +│ └── ConnectionRecoveryTest.kt # ← the UNIT test (RTN16*) │ -└── realtime/ - ├── unit/connection/ - │ └── ConnectionRecoveryTest.kt # ← the UNIT test (RTN16*) - └── integration/proxy/ - └── AuthReauthTest.kt # ← the PROXY test (RTN22, RTC8a) +└── integration/ # ── INTEGRATION TESTS (real backend) ── + └── proxy/ + └── realtime/ + └── AuthReauthTest.kt # ← the PROXY test (RTN22, RTC8a) ``` -The mental model: **`uts/infra/` powers unit tests, `test/helper/` powers proxy tests, and `Utils.kt` -serves both.** +The mental model: **`infra/unit/` powers the unit tests, `infra/integration/` powers the integration +tests, and `infra/Utils.kt` serves both.** The `unit/` ↔ `infra/unit/` and `integration/` ↔ +`infra/integration/` pairing is what the `runUtsUnitTests` / `runUtsIntegrationTests` Gradle tasks +key off (§12). --- @@ -472,14 +485,17 @@ listener — it re-evaluates the predicate every `interval` until it holds or th | `awaitChannelState` | `(channel, target, timeout=5s)` | same, for a channel's state | | `pollUntil` | `(timeout=15s, interval=100ms) { condition }` | suspend until a boolean predicate holds — used in proxy tests to wait on real network/proxy state, e.g. `pollUntil { authCallbackCount.get() > original }` | -`types/Utils.kt` adds one tiny convenience: a `ConnectionDetails { … }` builder DSL so tests can write -`ConnectionDetails { connectionKey = "key-1"; connectionStateTtl = 120000L }`. +A second `Utils.kt` under `infra/unit/` adds the `ConnectionDetails { … }` builder DSL so tests can +write `ConnectionDetails { connectionKey = "key-1"; connectionStateTtl = 120000L }`. Since this file +no longer sits in the `io.ably.lib.types` package, it can't call `ConnectionDetails`'s package-private +constructor directly — it obtains an instance **reflectively** (the same package-private-access +technique used by `liveobjects/.../TestUtils.kt`). See Appendix B.1. --- ## 9. Walkthrough: the Unit Test (`ConnectionRecoveryTest`) -**File:** `uts/.../realtime/unit/connection/ConnectionRecoveryTest.kt` +**File:** `uts/.../uts/unit/realtime/ConnectionRecoveryTest.kt` (package `io.ably.lib.uts.unit.realtime`) **Tier:** Unit (mocked WebSocket, no network). **Spec area:** RTN16 — connection recovery via the `recover` option and `createRecoveryKey()`. @@ -540,7 +556,7 @@ client output, and the env-gated deviation pattern. ## 10. Walkthrough: the Proxy Test (`AuthReauthTest`) -**File:** `uts/.../realtime/integration/proxy/AuthReauthTest.kt` +**File:** `uts/.../uts/integration/proxy/realtime/AuthReauthTest.kt` (package `io.ably.lib.uts.integration.proxy.realtime`) **Tier:** Proxy integration (real sandbox + uts-proxy). **Spec points:** RTN22 (server-initiated re-authentication) and RTC8a (the client sends an AUTH frame with renewed auth details). Unit-test counterparts: `server_initiated_reauth_test.md`, @@ -614,7 +630,7 @@ filter by `type`/`direction`/`message.action`). ## 11. Deviations: when the SDK disagrees with the spec -`uts/.../io/ably/lib/deviations.md` is the single catalogue of every place the ably-java SDK behaves +`uts/.../io/ably/lib/uts/deviations.md` is the single catalogue of every place the ably-java SDK behaves differently from the features spec, discovered during translation. Each entry records: the **spec point**, **what the spec requires**, **what the SDK does**, the **root cause** (file/function, where known), the **workaround in tests**, and the **affected tests**. @@ -640,24 +656,35 @@ Current entries relevant to the two tests: ## 12. How to Run the Tests +There are two custom Gradle tasks (registered in `uts/build.gradle.kts`), filtered by package — they +mirror `runLiveObjectsUnitTests` / `runLiveObjectsIntegrationTests` in the `liveobjects` module: + ```bash -# All UTS tests (unit + proxy). Proxy suites download/launch the proxy automatically. -./gradlew :uts:test +# Unit tests only — io.ably.lib.uts.unit.* (fast, no network). This is the PR gate. +./gradlew :uts:runUtsUnitTests -# Just the unit test class: -./gradlew :uts:test --tests "io.ably.lib.realtime.unit.connection.ConnectionRecoveryTest" +# Integration tests only — io.ably.lib.uts.integration.* (real sandbox; downloads/launches the proxy). +./gradlew :uts:runUtsIntegrationTests -# Just the proxy test class (needs network access to the sandbox + GitHub for the proxy binary): -./gradlew :uts:test --tests "io.ably.lib.realtime.integration.proxy.AuthReauthTest" +# Everything (the default Test task still runs both): +./gradlew :uts:test + +# Just one test class (works with any of the tasks above): +./gradlew :uts:runUtsUnitTests --tests "io.ably.lib.uts.unit.realtime.ConnectionRecoveryTest" +./gradlew :uts:runUtsIntegrationTests --tests "io.ably.lib.uts.integration.proxy.realtime.AuthReauthTest" # Turn on the spec-correct (currently failing) deviation assertions: -RUN_DEVIATIONS=1 ./gradlew :uts:test --tests "*ConnectionRecoveryTest*" +RUN_DEVIATIONS=1 ./gradlew :uts:runUtsUnitTests --tests "*ConnectionRecoveryTest*" # Run proxy tests against a locally built proxy instead of a GitHub release: -./gradlew :uts:test -Duts.proxy.localPath=/path/to/uts-proxy # or .tar.gz +./gradlew :uts:runUtsIntegrationTests -Duts.proxy.localPath=/path/to/uts-proxy # or .tar.gz # (equivalently: export UTS_PROXY_LOCAL_PATH=/path/to/uts-proxy) ``` +**Where CI runs them:** `runUtsUnitTests` is part of the `check.yml` gate (alongside +`runLiveObjectsUnitTests`); `runUtsIntegrationTests` runs in the `check-uts` job of +`integration-test.yml` (alongside `check-liveobjects`). + Notes: - `ProxyManager` **advises** running proxy suites single-fork (`maxParallelForks = 1`) because they share the control port (10100). This is not currently set in `uts/build.gradle.kts`; it isn't @@ -788,7 +815,7 @@ sees the control plane; the test never speaks the data plane directly. A one-stop table of every Kotlin source file under `uts/src/test/` and the SDK seams they use, so nothing is left implicit. -### B.1 Unit-test infrastructure — `io.ably.lib.uts.infra` +### B.1 Unit-test infrastructure — `io.ably.lib.uts.infra.unit` | File | Key public surface | Role | |------|--------------------|------| @@ -803,28 +830,28 @@ nothing is left implicit. | `DefaultPendingRequest.kt` | `DefaultPendingRequest : PendingRequest` | HTTP impl backed by a `CompletableDeferred`. | | `MockEvent.kt` | `sealed class MockEvent`: `ConnectionAttempt`, `ConnectionEstablished`, `ConnectionRefused`, `ConnectionTimeout`, `DnsError`, `HttpRequest`, `SentToClient`, `Disconnected`, `ClientClose`, `MessageFromClient` | Ordered, typed log of everything that happened on a mock transport. | | `FakeClock.kt` | `FakeClock : Clock` (`advance(ms\|Duration)`, `pendingTaskCount(name)`, `currentTimeMillis`, `nanoTime`, `newTimer`, `waitOn`) | Virtual clock + virtual timers; deterministic time. | +| `Utils.kt` | `ConnectionDetails { }` builder | Test-only `ConnectionDetails` DSL; instantiates the type via its **package-private constructor reflectively** (see §8). | -### B.2 Proxy/sandbox infrastructure — `io.ably.lib.test.helper` +### B.2 Integration infrastructure — `io.ably.lib.uts.infra.integration` (and `…integration.proxy`) | File | Key public surface | Role | |------|--------------------|------| -| `ProxyManager.kt` | `object ProxyManager`: `ensureProxy(timeoutMs)`, `stopProxy()`, `CONTROL_PORT=10100`, `sandboxRealtimeHost`, `sandboxRestHost`; pinned `PROXY_VERSION=v0.3.0` + per-arch checksums; `uts.proxy.localPath` override | Downloads/verifies/launches the `uts-proxy` binary; one shared process per run. | -| `ProxySession.kt` | `class ProxySession` (`create(rules,port,timeoutMs,realtimeHost,restHost)`, `addRules`, `triggerAction`, `getLog(): List`, `close`, `sessionId`, `proxyPort`, `proxyHost`); `data class Event`; `typealias ProxyRule`; rule builders `wsConnectRule`/`wsFrameToClientRule`/`wsFrameToServerRule`/`httpRequestRule`; `ClientOptionsBuilder.connectThroughProxy(session)` | Typed client for the proxy control REST API + client wiring. | -| `SandboxApp.kt` | `class SandboxApp` (`create()`, `delete()`, `appId`, `defaultKey`, `keys`) | Provisions/tears down a throwaway sandbox app from `ably-common`'s `test-app-setup.json`. | +| `proxy/ProxyManager.kt` | `object ProxyManager`: `ensureProxy(timeoutMs)`, `stopProxy()`, `CONTROL_PORT=10100`, `sandboxRealtimeHost`, `sandboxRestHost`; pinned `PROXY_VERSION=v0.3.0` + per-arch checksums; `uts.proxy.localPath` override | Downloads/verifies/launches the `uts-proxy` binary; one shared process per run. *(package `…integration.proxy`)* | +| `proxy/ProxySession.kt` | `class ProxySession` (`create(rules,port,timeoutMs,realtimeHost,restHost)`, `addRules`, `triggerAction`, `getLog(): List`, `close`, `sessionId`, `proxyPort`, `proxyHost`); `data class Event`; `typealias ProxyRule`; rule builders `wsConnectRule`/`wsFrameToClientRule`/`wsFrameToServerRule`/`httpRequestRule`; `ClientOptionsBuilder.connectThroughProxy(session)` | Typed client for the proxy control REST API + client wiring. *(package `…integration.proxy`)* | +| `SandboxApp.kt` | `class SandboxApp` (`create()`, `delete()`, `appId`, `defaultKey`, `keys`) | Provisions/tears down a throwaway sandbox app from `ably-common`'s `test-app-setup.json`. *(package `…integration`)* | ### B.3 Shared helpers & tests | File | Key public surface | Role | |------|--------------------|------| -| `io/ably/lib/Utils.kt` | `awaitState(client,target,timeout=5s)`, `awaitChannelState(channel,target,timeout=5s)`, `pollUntil(timeout=15s,interval=100ms){ }` | Wall-clock coroutine waits; listener registered before state check. | -| `io/ably/lib/types/Utils.kt` | `ConnectionDetails { }` builder | DSL sugar for building `ConnectionDetails` in tests. | -| `realtime/unit/connection/ConnectionRecoveryTest.kt` | 6 `@Test`s: RTN16g/g1, RTN16g2, RTN16k, RTN16f, RTN16f1, RTN16j | Unit tier — connection recovery (mocked WS, FakeClock, env-gated deviations). | -| `realtime/integration/proxy/AuthReauthTest.kt` | 1 `@Test` (two `@UTS`: RTN22, RTC8a) | Proxy tier — server-initiated re-authentication. | +| `infra/Utils.kt` | `awaitState(client,target,timeout=5s)`, `awaitChannelState(channel,target,timeout=5s)`, `pollUntil(timeout=15s,interval=100ms){ }` | Shared wall-clock coroutine waits (package `io.ably.lib.uts.infra`); listener registered before state check. | +| `unit/realtime/ConnectionRecoveryTest.kt` | 6 `@Test`s: RTN16g/g1, RTN16g2, RTN16k, RTN16f, RTN16f1, RTN16j | Unit tier (`io.ably.lib.uts.unit.realtime`) — connection recovery (mocked WS, FakeClock, env-gated deviations). | +| `integration/proxy/realtime/AuthReauthTest.kt` | 1 `@Test` (two `@UTS`: RTN22, RTC8a) | Integration tier (`io.ably.lib.uts.integration.proxy.realtime`) — server-initiated re-authentication. | | `deviations.md` | RTN16f, RTN16g2, RTL13b, RTL13c | Catalogue of SDK-vs-spec divergences. | > **Coverage note:** at the time of writing, the `uts/` module contains exactly **two test classes** > (**7** `@Test` methods total: 6 in `ConnectionRecoveryTest` + 1 in `AuthReauthTest`). The infrastructure under -> `uts/infra/` and `test/helper/` is built out far beyond what these two tests exercise (full HTTP +> `infra/unit/` and `infra/integration/` is built out far beyond what these two tests exercise (full HTTP > mock, all four rule builders, REST proxy wiring, etc.), anticipating the broader UTS coverage > catalogued in [`completion-status.md`](https://github.com/ably/specification/blob/main/uts/docs/completion-status.md). @@ -841,8 +868,8 @@ nothing is left implicit. | Proxy control API, rule format, action numbers | [`uts/realtime/integration/helpers/proxy.md`](https://github.com/ably/specification/blob/main/uts/realtime/integration/helpers/proxy.md) | | SDK seams | `lib/.../debug/DebugOptions.java`, `lib/.../util/Clock.java` | | Module wiring | `uts/build.gradle.kts`, `settings.gradle.kts` | -| Unit mocks | `uts/.../uts/infra/*` | -| Proxy/sandbox helpers | `uts/.../test/helper/*` | -| Async helpers | `uts/.../io/ably/lib/Utils.kt`, `…/types/Utils.kt` | -| The two example tests | `…/unit/connection/ConnectionRecoveryTest.kt`, `…/integration/proxy/AuthReauthTest.kt` | -| Deviations | `uts/.../io/ably/lib/deviations.md` | +| Unit mocks | `uts/.../uts/infra/unit/*` | +| Integration helpers | `uts/.../uts/infra/integration/*` (+ `…/integration/proxy/*`) | +| Async helpers | `uts/.../uts/infra/Utils.kt` (awaits), `…/uts/infra/unit/Utils.kt` (ConnectionDetails builder) | +| The two example tests | `…/uts/unit/realtime/ConnectionRecoveryTest.kt`, `…/uts/integration/proxy/realtime/AuthReauthTest.kt` | +| Deviations | `uts/.../io/ably/lib/uts/deviations.md` | diff --git a/uts/build.gradle.kts b/uts/build.gradle.kts index 585d1f239..4638ac1b3 100644 --- a/uts/build.gradle.kts +++ b/uts/build.gradle.kts @@ -35,3 +35,15 @@ tasks.withType().configureEach { .getOrElse(""), ) } + +tasks.register("runUtsUnitTests") { + filter { + includeTestsMatching("io.ably.lib.uts.unit.*") + } +} + +tasks.register("runUtsIntegrationTests") { + filter { + includeTestsMatching("io.ably.lib.uts.integration.*") + } +} diff --git a/uts/index.html b/uts/index.html index 739829046..43be8b045 100644 --- a/uts/index.html +++ b/uts/index.html @@ -190,9 +190,9 @@

    1 Introduction: What is UTS?

    2 The Three Test Tiers

    UTS divides tests into three tiers by what infrastructure they need and what confidence they give. This split is the key to understanding the whole uts/ module — the two example tests sit in two different tiers.

    -

    Unit mocked

    Transport: mocked (MockWebSocket, MockHttpClient). Backend: none.
    Client-side logic: state machines, request formation, response parsing, timers. Fast & deterministic.
    → ConnectionRecoveryTest.kt

    +

    Unit mocked

    Transport: mocked (MockWebSocket, MockHttpClient). Backend: none.
    Client-side logic: state machines, request formation, response parsing, timers. Fast & deterministic.
    → unit/realtime/ConnectionRecoveryTest.kt

    Direct sandbox real net

    Transport: real. Backend: real Ably sandbox.
    Happy-path interop: connect, publish, subscribe. No fault injection.
    (not among the two example tests)

    -

    Proxy integration faults

    Transport: real, through a programmable proxy. Backend: real sandbox.
    Fault behaviour: dropped connections, injected errors, timeouts, re-auth.
    → AuthReauthTest.kt

    +

    Proxy integration faults

    Transport: real, through a programmable proxy. Backend: real sandbox.
    Fault behaviour: dropped connections, injected errors, timeouts, re-auth.
    → integration/proxy/realtime/AuthReauthTest.kt

    Key principles (from integration-testing.md):

    +
    Mental model

    infra/unit/ powers the unit tests · infra/integration/ powers the integration tests · infra/Utils.kt serves both. The unit/infra/unit/ and integration/infra/integration/ pairing is what the runUtsUnitTests / runUtsIntegrationTests Gradle tasks key off (§12).

    @@ -470,7 +477,7 @@

    8 Shared Async Helpers

    awaitChannelState(channel, target, timeout=5s)same, for a channel's state pollUntil(timeout=15s, interval=100ms) { cond }suspend until a boolean predicate holds — used in proxy tests to wait on real network/proxy state -

    types/Utils.kt adds a ConnectionDetails { … } builder DSL so tests can write ConnectionDetails { connectionKey = "key-1"; connectionStateTtl = 120000L }.

    +

    A second Utils.kt under infra/unit/ adds the ConnectionDetails { … } builder DSL so tests can write ConnectionDetails { connectionKey = "key-1"; connectionStateTtl = 120000L }. Since this file no longer sits in the io.ably.lib.types package, it can't call ConnectionDetails's package-private constructor directly — it obtains an instance reflectively (the same package-private-access technique used by liveobjects/.../TestUtils.kt). See Appendix B.1.

    @@ -574,26 +581,32 @@

    11 Deviations: when the SDK disagrees w

    12 How to Run the Tests

    -
    # All UTS tests (unit + proxy). Proxy suites download/launch the proxy automatically.
    -./gradlew :uts:test
    +

    Two custom Gradle tasks (registered in uts/build.gradle.kts), filtered by package — they mirror runLiveObjectsUnitTests / runLiveObjectsIntegrationTests in the liveobjects module:

    +
    # Unit tests only — io.ably.lib.uts.unit.*  (fast, no network). This is the PR gate.
    +./gradlew :uts:runUtsUnitTests
    +
    +# Integration tests only — io.ably.lib.uts.integration.*  (real sandbox; downloads/launches the proxy).
    +./gradlew :uts:runUtsIntegrationTests
     
    -# Just the unit test class:
    -./gradlew :uts:test --tests "io.ably.lib.realtime.unit.connection.ConnectionRecoveryTest"
    +# Everything (the default Test task still runs both):
    +./gradlew :uts:test
     
    -# Just the proxy test class (needs network: sandbox + GitHub for the proxy binary):
    -./gradlew :uts:test --tests "io.ably.lib.realtime.integration.proxy.AuthReauthTest"
    +# Just one test class (works with any of the tasks above):
    +./gradlew :uts:runUtsUnitTests --tests "io.ably.lib.uts.unit.realtime.ConnectionRecoveryTest"
    +./gradlew :uts:runUtsIntegrationTests --tests "io.ably.lib.uts.integration.proxy.realtime.AuthReauthTest"
     
     # Turn on the spec-correct (currently failing) deviation assertions:
    -RUN_DEVIATIONS=1 ./gradlew :uts:test --tests "*ConnectionRecoveryTest*"
    +RUN_DEVIATIONS=1 ./gradlew :uts:runUtsUnitTests --tests "*ConnectionRecoveryTest*"
     
     # Run proxy tests against a locally built proxy instead of a GitHub release:
    -./gradlew :uts:test -Duts.proxy.localPath=/path/to/uts-proxy            # or .tar.gz
    +./gradlew :uts:runUtsIntegrationTests -Duts.proxy.localPath=/path/to/uts-proxy   # or .tar.gz
     #   (equivalently: export UTS_PROXY_LOCAL_PATH=/path/to/uts-proxy)
    +

    Where CI runs them: runUtsUnitTests is part of the check.yml gate (alongside runLiveObjectsUnitTests); runUtsIntegrationTests runs in the check-uts job of integration-test.yml (alongside check-liveobjects).

    Notes:

    • ProxyManager advises running proxy suites single-fork (maxParallelForks = 1) because they share the control port (10100). Not currently set in uts/build.gradle.kts; not yet exercised because there is only one proxy test class.
    • Proxy/sandbox tests need outbound network (sandbox + GitHub releases on first run; the binary is then cached under ~/.cache/uts-proxy/).
    • -
    • Before pushing, run the static-analysis gate (from CLAUDE.md): ./gradlew checkWithCodenarc checkstyleMain checkstyleTest — Checkstyle is Java-only and easy to miss; remember no star imports. (CI's full line additionally runs runUnitTests runLiveObjectsUnitTests :uts:test.)
    • +
    • Before pushing, run the static-analysis gate (from CLAUDE.md): ./gradlew checkWithCodenarc checkstyleMain checkstyleTest — Checkstyle is Java-only and easy to miss; remember no star imports. (CI's full line additionally runs runUnitTests runLiveObjectsUnitTests :uts:runUtsUnitTests.)
    @@ -713,7 +726,7 @@

    A.2 Proxy integration test — real backend through the fault-injecting prox

    B Appendix B: Per-File API Reference

    -

    B.1 Unit-test infrastructure — io.ably.lib.uts.infra

    +

    B.1 Unit-test infrastructure — io.ably.lib.uts.infra.unit

    @@ -727,26 +740,26 @@

    B.1 Unit-test infrastructure — io.ably.lib.uts.infra

    +
    FileKey public surfaceRole
    ClientFactories.ktClientOptionsBuilder (extends DebugOptions), TestRealtimeClient { }, TestRestClient { }, install(mock), enableFakeTimers(clock)Entry point for building a mocked SDK client; seeds dummy key, forces JSON.
    DefaultPendingRequest.ktDefaultPendingRequest : PendingRequestHTTP impl backed by a CompletableDeferred<HttpResponse>.
    MockEvent.ktsealed class MockEvent: ConnectionAttempt, ConnectionEstablished, ConnectionRefused, ConnectionTimeout, DnsError, HttpRequest, SentToClient, Disconnected, ClientClose, MessageFromClientOrdered, typed log of everything on a mock transport.
    FakeClock.ktFakeClock : Clock (advance(ms|Duration), pendingTaskCount(name), currentTimeMillis, nanoTime, newTimer, waitOn)Virtual clock + virtual timers; deterministic time.
    Utils.ktConnectionDetails { } builderTest-only ConnectionDetails DSL; instantiates the type via its package-private constructor reflectively (see §8).
    -

    B.2 Proxy/sandbox infrastructure — io.ably.lib.test.helper

    +

    B.2 Integration infrastructure — io.ably.lib.uts.infra.integration (and …integration.proxy)

    - - - + + +
    FileKey public surfaceRole
    ProxyManager.ktobject ProxyManager: ensureProxy(timeoutMs), stopProxy(), CONTROL_PORT=10100, sandboxRealtimeHost, sandboxRestHost; pinned PROXY_VERSION=v0.3.0 + per-arch checksums; uts.proxy.localPath overrideDownloads/verifies/launches the uts-proxy binary; one shared process per run.
    ProxySession.ktclass ProxySession (create(rules,port,timeoutMs,realtimeHost,restHost), addRules, triggerAction, getLog(): List<Event>, close, sessionId, proxyPort, proxyHost); data class Event; typealias ProxyRule; rule builders wsConnectRule/wsFrameToClientRule/wsFrameToServerRule/httpRequestRule; ClientOptionsBuilder.connectThroughProxy(session)Typed client for the proxy control REST API + client wiring.
    SandboxApp.ktclass SandboxApp (create(), delete(), appId, defaultKey, keys)Provisions/tears down a throwaway sandbox app from ably-common's test-app-setup.json.
    proxy/ProxyManager.ktobject ProxyManager: ensureProxy(timeoutMs), stopProxy(), CONTROL_PORT=10100, sandboxRealtimeHost, sandboxRestHost; pinned PROXY_VERSION=v0.3.0 + per-arch checksums; uts.proxy.localPath overrideDownloads/verifies/launches the uts-proxy binary; one shared process per run. (package …integration.proxy)
    proxy/ProxySession.ktclass ProxySession (create(rules,port,timeoutMs,realtimeHost,restHost), addRules, triggerAction, getLog(): List<Event>, close, sessionId, proxyPort, proxyHost); data class Event; typealias ProxyRule; rule builders wsConnectRule/wsFrameToClientRule/wsFrameToServerRule/httpRequestRule; ClientOptionsBuilder.connectThroughProxy(session)Typed client for the proxy control REST API + client wiring. (package …integration.proxy)
    SandboxApp.ktclass SandboxApp (create(), delete(), appId, defaultKey, keys)Provisions/tears down a throwaway sandbox app from ably-common's test-app-setup.json. (package …integration)

    B.3 Shared helpers & tests

    - - - - + + +
    FileKey public surfaceRole
    io/ably/lib/Utils.ktawaitState(client,target,timeout=5s), awaitChannelState(channel,target,timeout=5s), pollUntil(timeout=15s,interval=100ms){ }Wall-clock coroutine waits; listener registered before state check.
    io/ably/lib/types/Utils.ktConnectionDetails { } builderDSL sugar for building ConnectionDetails in tests.
    realtime/unit/connection/ConnectionRecoveryTest.kt6 @Tests: RTN16g/g1, RTN16g2, RTN16k, RTN16f, RTN16f1, RTN16jUnit tier — connection recovery (mocked WS, FakeClock, env-gated deviations).
    realtime/integration/proxy/AuthReauthTest.kt1 @Test (two @UTS: RTN22, RTC8a)Proxy tier — server-initiated re-authentication.
    infra/Utils.ktawaitState(client,target,timeout=5s), awaitChannelState(channel,target,timeout=5s), pollUntil(timeout=15s,interval=100ms){ }Shared wall-clock coroutine waits (package io.ably.lib.uts.infra); listener registered before state check.
    unit/realtime/ConnectionRecoveryTest.kt6 @Tests: RTN16g/g1, RTN16g2, RTN16k, RTN16f, RTN16f1, RTN16jUnit tier (io.ably.lib.uts.unit.realtime) — connection recovery (mocked WS, FakeClock, env-gated deviations).
    integration/proxy/realtime/AuthReauthTest.kt1 @Test (two @UTS: RTN22, RTC8a)Integration tier (io.ably.lib.uts.integration.proxy.realtime) — server-initiated re-authentication.
    deviations.mdRTN16f, RTN16g2, RTL13b, RTL13cCatalogue of SDK-vs-spec divergences.
    -
    +
    Coverage note

    At the time of writing, the uts/ module contains exactly two test classes (7 @Test methods total: 6 in ConnectionRecoveryTest + 1 in AuthReauthTest). The infrastructure under infra/unit/ and infra/integration/ is built out far beyond what these two tests exercise (full HTTP mock, all four rule builders, REST proxy wiring), anticipating the broader UTS coverage catalogued in completion-status.md.

    @@ -760,11 +773,11 @@

    Source map — where each fact comes from

    Proxy control API, rule format, action numbersuts/realtime/integration/helpers/proxy.md SDK seamslib/.../debug/DebugOptions.java, lib/.../util/Clock.java Module wiringuts/build.gradle.kts, settings.gradle.kts - Unit mocksuts/.../uts/infra/* - Proxy/sandbox helpersuts/.../test/helper/* - Async helpersuts/.../io/ably/lib/Utils.kt, …/types/Utils.kt - The two example tests…/unit/connection/ConnectionRecoveryTest.kt, …/integration/proxy/AuthReauthTest.kt - Deviationsuts/.../io/ably/lib/deviations.md + Unit mocksuts/.../uts/infra/unit/* + Integration helpersuts/.../uts/infra/integration/* (+ …/integration/proxy/*) + Async helpersuts/.../uts/infra/Utils.kt (awaits), …/uts/infra/unit/Utils.kt (ConnectionDetails builder) + The two example tests…/uts/unit/realtime/ConnectionRecoveryTest.kt, …/uts/integration/proxy/realtime/AuthReauthTest.kt + Deviationsuts/.../io/ably/lib/uts/deviations.md

    Generated from README.md (in this uts/ directory). Single self-contained HTML file — no external assets.

    diff --git a/uts/src/test/kotlin/io/ably/lib/types/Utils.kt b/uts/src/test/kotlin/io/ably/lib/types/Utils.kt deleted file mode 100644 index 15c11d557..000000000 --- a/uts/src/test/kotlin/io/ably/lib/types/Utils.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.ably.lib.types - -fun ConnectionDetails(init: ConnectionDetails.() -> Unit) = ConnectionDetails().apply(init) diff --git a/uts/src/test/kotlin/io/ably/lib/deviations.md b/uts/src/test/kotlin/io/ably/lib/uts/deviations.md similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/deviations.md rename to uts/src/test/kotlin/io/ably/lib/uts/deviations.md diff --git a/uts/src/test/kotlin/io/ably/lib/Utils.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/Utils.kt similarity index 98% rename from uts/src/test/kotlin/io/ably/lib/Utils.kt rename to uts/src/test/kotlin/io/ably/lib/uts/infra/Utils.kt index 5debec7e7..24180d8f6 100644 --- a/uts/src/test/kotlin/io/ably/lib/Utils.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/Utils.kt @@ -1,4 +1,4 @@ -package io.ably.lib +package io.ably.lib.uts.infra import io.ably.lib.realtime.AblyRealtime import io.ably.lib.realtime.Channel diff --git a/uts/src/test/kotlin/io/ably/lib/test/helper/SandboxApp.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt similarity index 97% rename from uts/src/test/kotlin/io/ably/lib/test/helper/SandboxApp.kt rename to uts/src/test/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt index 901ac5902..a697618a0 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/helper/SandboxApp.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt @@ -1,7 +1,8 @@ -package io.ably.lib.test.helper +package io.ably.lib.uts.infra.integration import com.google.gson.JsonElement import com.google.gson.JsonParser +import io.ably.lib.uts.infra.integration.proxy.ProxyManager import io.ktor.client.* import io.ktor.client.engine.cio.* import io.ktor.client.network.sockets.* diff --git a/uts/src/test/kotlin/io/ably/lib/test/helper/ProxyManager.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt similarity index 99% rename from uts/src/test/kotlin/io/ably/lib/test/helper/ProxyManager.kt rename to uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt index eed463ba8..099f404bf 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/helper/ProxyManager.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt @@ -1,4 +1,4 @@ -package io.ably.lib.test.helper +package io.ably.lib.uts.infra.integration.proxy import io.ktor.client.HttpClient import io.ktor.client.call.body diff --git a/uts/src/test/kotlin/io/ably/lib/test/helper/ProxySession.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt similarity index 99% rename from uts/src/test/kotlin/io/ably/lib/test/helper/ProxySession.kt rename to uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt index 52fe6b94a..cee13b22b 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/helper/ProxySession.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt @@ -1,9 +1,9 @@ -package io.ably.lib.test.helper +package io.ably.lib.uts.infra.integration.proxy import com.google.gson.Gson import com.google.gson.JsonObject import com.google.gson.reflect.TypeToken -import io.ably.lib.uts.infra.ClientOptionsBuilder +import io.ably.lib.uts.infra.unit.ClientOptionsBuilder import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.HttpTimeout diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/ClientFactories.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt similarity index 95% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/ClientFactories.kt rename to uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt index e663bfa27..94a055cdd 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/ClientFactories.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.infra +package io.ably.lib.uts.infra.unit import io.ably.lib.debug.DebugOptions import io.ably.lib.realtime.AblyRealtime diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/DefaultPendingConnection.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt similarity index 97% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/DefaultPendingConnection.kt rename to uts/src/test/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt index 7552371f9..3cfd6df53 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/DefaultPendingConnection.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.infra +package io.ably.lib.uts.infra.unit import io.ably.lib.network.WebSocketListener import io.ably.lib.types.ProtocolMessage diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/DefaultPendingRequest.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt similarity index 97% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/DefaultPendingRequest.kt rename to uts/src/test/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt index fe92ee5b9..e77ab518d 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/DefaultPendingRequest.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.infra +package io.ably.lib.uts.infra.unit import io.ably.lib.network.FailedConnectionException import io.ably.lib.network.HttpBody diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/FakeClock.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt similarity index 98% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/FakeClock.kt rename to uts/src/test/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt index f17bddf82..0b0c50d2d 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/FakeClock.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.infra +package io.ably.lib.uts.infra.unit import io.ably.lib.util.Clock import io.ably.lib.util.AblyTimer diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/MockEvent.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockEvent.kt similarity index 97% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/MockEvent.kt rename to uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockEvent.kt index 5d9d78b54..99fea4195 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/MockEvent.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockEvent.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.infra +package io.ably.lib.uts.infra.unit import io.ably.lib.types.ProtocolMessage import java.net.URL diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/MockHttpClient.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt similarity index 98% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/MockHttpClient.kt rename to uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt index 9e0d142c0..0397cf6a9 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/MockHttpClient.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.infra +package io.ably.lib.uts.infra.unit import io.ably.lib.debug.DebugOptions import io.ably.lib.network.HttpEngine diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/MockHttpEngine.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockHttpEngine.kt similarity index 95% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/MockHttpEngine.kt rename to uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockHttpEngine.kt index ed1b45744..dfe159ded 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/MockHttpEngine.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockHttpEngine.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.infra +package io.ably.lib.uts.infra.unit import io.ably.lib.network.FailedConnectionException import io.ably.lib.network.HttpCall @@ -40,7 +40,7 @@ internal class MockHttpCall( // Phase 2 — request val rd = CompletableDeferred().also { respDeferred = it } - onRequest(DefaultPendingRequest(request, rd)) + onRequest(io.ably.lib.uts.infra.unit.DefaultPendingRequest(request, rd)) rd.await() } diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/MockWebSocket.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt similarity index 99% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/MockWebSocket.kt rename to uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt index 62ef8276a..e57227bf7 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/MockWebSocket.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt @@ -1,9 +1,8 @@ -package io.ably.lib.uts.infra +package io.ably.lib.uts.infra.unit import io.ably.lib.debug.DebugOptions import io.ably.lib.network.WebSocketEngineFactory import io.ably.lib.network.WebSocketListener -import io.ably.lib.types.ConnectionDetails import io.ably.lib.types.ProtocolMessage import io.ably.lib.types.ProtocolSerializer import io.ably.lib.util.Serialisation diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/MockWebSocketEngineFactory.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt similarity index 98% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/MockWebSocketEngineFactory.kt rename to uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt index 3832d920c..2a41b518c 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/MockWebSocketEngineFactory.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.infra +package io.ably.lib.uts.infra.unit import io.ably.lib.network.EngineType import io.ably.lib.network.WebSocketClient diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/PendingConnection.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/PendingConnection.kt similarity index 97% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/PendingConnection.kt rename to uts/src/test/kotlin/io/ably/lib/uts/infra/unit/PendingConnection.kt index 6092ca894..588f9759d 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/PendingConnection.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/PendingConnection.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.infra +package io.ably.lib.uts.infra.unit import io.ably.lib.types.ProtocolMessage import java.net.URLDecoder diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/PendingRequest.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/PendingRequest.kt similarity index 95% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/PendingRequest.kt rename to uts/src/test/kotlin/io/ably/lib/uts/infra/unit/PendingRequest.kt index baf0920a2..43d28ff4b 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/PendingRequest.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/PendingRequest.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.infra +package io.ably.lib.uts.infra.unit import java.net.URL import kotlin.time.Duration diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/Utils.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/Utils.kt new file mode 100644 index 000000000..34527348f --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/Utils.kt @@ -0,0 +1,20 @@ +package io.ably.lib.uts.infra.unit + +import io.ably.lib.types.ConnectionDetails + +/** + * Test-only builder DSL for [ConnectionDetails], e.g. + * `ConnectionDetails { connectionKey = "key-1"; connectionStateTtl = 120_000L }`. + * + * [ConnectionDetails]'s no-arg constructor is package-private to `io.ably.lib.types`, so it cannot be + * invoked directly from this package. We obtain an instance reflectively via [newConnectionDetails] — + * the same package-private-access technique used by `liveobjects/.../TestUtils.kt`. + */ +fun ConnectionDetails(init: ConnectionDetails.() -> Unit): ConnectionDetails = + newConnectionDetails().apply(init) + +/** Reflectively invokes [ConnectionDetails]'s package-private no-arg constructor. */ +private fun newConnectionDetails(): ConnectionDetails = + ConnectionDetails::class.java.getDeclaredConstructor() + .apply { isAccessible = true } + .newInstance() diff --git a/uts/src/test/kotlin/io/ably/lib/realtime/integration/proxy/AuthReauthTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt similarity index 92% rename from uts/src/test/kotlin/io/ably/lib/realtime/integration/proxy/AuthReauthTest.kt rename to uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt index e6df1e4ca..d14e9405c 100644 --- a/uts/src/test/kotlin/io/ably/lib/realtime/integration/proxy/AuthReauthTest.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt @@ -1,15 +1,15 @@ -package io.ably.lib.realtime.integration.proxy +package io.ably.lib.uts.integration.proxy.realtime -import io.ably.lib.awaitState -import io.ably.lib.pollUntil import io.ably.lib.realtime.ConnectionState import io.ably.lib.rest.AblyRest import io.ably.lib.rest.Auth -import io.ably.lib.test.helper.ProxyManager -import io.ably.lib.test.helper.ProxySession -import io.ably.lib.test.helper.SandboxApp -import io.ably.lib.test.helper.connectThroughProxy -import io.ably.lib.uts.infra.TestRealtimeClient +import io.ably.lib.uts.infra.awaitState +import io.ably.lib.uts.infra.integration.SandboxApp +import io.ably.lib.uts.infra.integration.proxy.ProxyManager +import io.ably.lib.uts.infra.integration.proxy.ProxySession +import io.ably.lib.uts.infra.integration.proxy.connectThroughProxy +import io.ably.lib.uts.infra.pollUntil +import io.ably.lib.uts.infra.unit.TestRealtimeClient import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterAll diff --git a/uts/src/test/kotlin/io/ably/lib/realtime/unit/connection/ConnectionRecoveryTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt similarity index 95% rename from uts/src/test/kotlin/io/ably/lib/realtime/unit/connection/ConnectionRecoveryTest.kt rename to uts/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt index b4152d695..9dd55ec44 100644 --- a/uts/src/test/kotlin/io/ably/lib/realtime/unit/connection/ConnectionRecoveryTest.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt @@ -1,18 +1,14 @@ -package io.ably.lib.realtime.unit.connection +package io.ably.lib.uts.unit.realtime -import io.ably.lib.uts.infra.TestRealtimeClient -import io.ably.lib.awaitChannelState -import io.ably.lib.awaitState +import io.ably.lib.uts.infra.unit.* import io.ably.lib.realtime.ChannelState import io.ably.lib.realtime.ConnectionState -import io.ably.lib.uts.infra.CONNECTED_MESSAGE -import io.ably.lib.uts.infra.FakeClock -import io.ably.lib.uts.infra.MockEvent -import io.ably.lib.uts.infra.MockWebSocket -import io.ably.lib.types.ConnectionDetails import io.ably.lib.types.ErrorInfo import io.ably.lib.types.ProtocolMessage import io.ably.lib.types.RecoveryKeyContext +import io.ably.lib.uts.infra.awaitChannelState +import io.ably.lib.uts.infra.awaitState +import io.ably.lib.uts.infra.pollUntil import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlin.test.* @@ -246,6 +242,11 @@ class ConnectionRecoveryTest { awaitState(client, ConnectionState.connected) mock.simulateDisconnect() + // Wait for the reconnect's connection attempt to be captured, not just for the CONNECTED state: + // right after simulateDisconnect() the state is still `connected`, so awaitState(connected) would + // short-circuit before the second attempt is recorded (the transient-state race called out in + // writing-test-specs.md). Gate on the second attempt actually arriving. + pollUntil { capturedQueryParams.size >= 2 } awaitState(client, ConnectionState.connected) assertEquals("recovered-key-xyz", capturedQueryParams[0]["recover"]) From a53996c4e0bb9f531ef896c13805ebaa798123ee Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 25 Jun 2026 17:12:56 +0530 Subject: [PATCH 32/40] docs(uts): document direct-sandbox tier and per-module test layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflect the new test-source structure in the UTS guide, website, and the uts-to-kotlin skill: - Add the direct-sandbox (`integration/standard//`) tier alongside the existing unit and proxy tiers, and document that every tier is now organised by module (`realtime`, `liveobjects`, …). - Update §2 tier table, §4.2 directory tree + mental model, §7.3 SandboxApp (shared by both integration kinds), and §12 run commands in README.md, and mirror all of it in index.html (tags verified balanced, sections intact). - Generalise the skill's spec→test path mapping to ``, add a direct-sandbox row, and split integration specs into fault-injecting (proxy) vs happy-path (direct sandbox) flows. - Correct stale "both tiers" wording now that there are three tiers. --- .claude/skills/uts-to-kotlin/SKILL.md | 69 ++++++++++++++++++--------- uts/README.md | 53 +++++++++++++------- uts/index.html | 32 ++++++++----- 3 files changed, 102 insertions(+), 52 deletions(-) diff --git a/.claude/skills/uts-to-kotlin/SKILL.md b/.claude/skills/uts-to-kotlin/SKILL.md index f2e8ffe35..096ddd606 100644 --- a/.claude/skills/uts-to-kotlin/SKILL.md +++ b/.claude/skills/uts-to-kotlin/SKILL.md @@ -59,17 +59,26 @@ Read the file at `$ARGUMENTS`. Identify: ## Step 2 — Determine output path and package -Map the spec path to a test path: +Map the spec path to a test path. **Tests are organised tier-first** (`unit/` vs `integration/standard/` +vs `integration/proxy/`), then **by module** (`realtime`, `rest`, `liveobjects`, …), all under the +`io.ably.lib.uts` package. (`…/uts/` below is shorthand for `uts/src/test/kotlin/io/ably/lib/uts/`.) -| Spec location | Test location | -|---|---| -| `.../uts/test/rest/unit/.md` | `uts/src/test/kotlin/io/ably/lib/rest/unit/Test.kt` | -| `.../uts/test/realtime/unit//.md` | `uts/src/test/kotlin/io/ably/lib/realtime/unit//Test.kt` | -| `.../uts/test/realtime/integration//.md` | `uts/src/test/kotlin/io/ably/lib/realtime/integration//Test.kt` | +| Spec location | Test location | Package | +|---|---|---| +| `...//unit/<…>/.md` | `…/uts/unit//Test.kt` | `io.ably.lib.uts.unit.` | +| `...//integration/<…>/.md` (direct sandbox) | `…/uts/integration/standard//Test.kt` | `io.ably.lib.uts.integration.standard.` | +| `...//integration/<…>/.md` (proxy) | `…/uts/integration/proxy//Test.kt` | `io.ably.lib.uts.integration.proxy.` | + +`` is the SDK area — `realtime`, `rest`, `liveobjects`, … (existing folders: `unit/realtime/`, +`unit/liveobjects/`). The spec's own `` grouping (e.g. `connection/`) is **not** carried into a +sub-package — tests sit directly under the tier/module folder (e.g. `connection_recovery_test.md` → +`…/uts/unit/realtime/ConnectionRecoveryTest.kt`). Class name: take the file name, strip `_test` suffix, convert `snake_case` → `PascalCase`, append `Test`. -**Integration specs that drive traffic through the programmable proxy** (they reference `create_proxy_session()`, proxy rules, or `uts/test/realtime/integration/helpers/proxy.md`) follow a different translation flow — see the **Proxy integration tests** section at the end of this skill instead of the unit-test rules below. +**Integration specs come in two kinds:** +- Those that **inject faults** (reference `create_proxy_session()`, proxy `rules`, `trigger_action`, `get_log`, or `uts/test/realtime/integration/helpers/proxy.md`) are **proxy** tests under `integration/proxy//` — follow the **Proxy integration tests** section at the end of this skill instead of the unit-test rules below. +- Those that exercise only **happy-path interop** against the real sandbox (no fault injection) are **direct sandbox** tests under `integration/standard//`. They use `SandboxApp` alone (no `ProxySession`), connecting straight to `ProxyManager.sandboxRealtimeHost` / `sandboxRestHost`. *(No example exists yet — model the suite setup/teardown on the proxy section but drop the `ProxySession`/`connectThroughProxy` wiring.)* Example: `connection_state_machine_test.md` → `ConnectionStateMachineTest` @@ -79,7 +88,13 @@ Package: derived from the output path under `kotlin/`. ## Step 3 — Read infrastructure files -Read ALL of files in `uts/src/test/kotlin/io/ably/lib/uts/infra` before generating any code (you need exact method signatures). +Infrastructure is split by tier under `uts/src/test/kotlin/io/ably/lib/uts/infra/`: + +- `infra/Utils.kt` — shared async helpers (`awaitState`, `awaitChannelState`, `pollUntil`), package `io.ably.lib.uts.infra`. +- `infra/unit/` — unit-test mocks/factories (`ClientFactories.kt`, `MockWebSocket.kt`, `MockHttpClient.kt`, `FakeClock.kt`, `MockEvent.kt`, the `PendingConnection`/`PendingRequest` pairs, and `Utils.kt` with the `ConnectionDetails { }` builder), package `io.ably.lib.uts.infra.unit`. +- `infra/integration/` + `infra/integration/proxy/` — proxy/sandbox helpers (`SandboxApp.kt`, `ProxyManager.kt`, `ProxySession.kt`) — see the **Proxy integration tests** section. + +For a **unit** test, read all files under `infra/unit/` plus `infra/Utils.kt` before generating any code (you need exact method signatures). ## Step 4 — Generate the Kotlin test file @@ -266,16 +281,14 @@ fun `RTN4a - description of what is being tested`() = runTest { ### File template ```kotlin -package io.ably.lib..unit[.] +package io.ably.lib.uts.unit.realtime // io.ably.lib.uts.unit. — realtime, rest, liveobjects, … -import io.ably.lib.TestRealtimeClient // or TestRestClient -import io.ably.lib.awaitChannelState // if testing channels -import io.ably.lib.awaitState -import io.ably.lib.realtime.ChannelState // if testing channels +import io.ably.lib.uts.infra.unit.* // TestRealtimeClient/TestRestClient, MockWebSocket, MockHttpClient, FakeClock, CONNECTED_MESSAGE, ConnectionDetails { } builder +import io.ably.lib.uts.infra.awaitState +import io.ably.lib.uts.infra.awaitChannelState // if testing channels +import io.ably.lib.uts.infra.pollUntil // if polling on a predicate +import io.ably.lib.realtime.ChannelState // if testing channels import io.ably.lib.realtime.ConnectionState -import io.ably.lib.test.mock.FakeClock // if using fake timers -import io.ably.lib.test.mock.MockWebSocket // or MockHttpClient -import io.ably.lib.types.ConnectionDetails import io.ably.lib.types.ErrorInfo import io.ably.lib.types.ProtocolMessage import kotlinx.coroutines.launch @@ -334,10 +347,18 @@ Fix any compilation errors and recompile until clean. Common issues: ## Step 6 — Run tests +Use the per-tier task that matches what you generated (both are registered in `uts/build.gradle.kts`): + ```bash -./gradlew :uts:test --tests "." +# Unit test (io.ably.lib.uts.unit.*) +./gradlew :uts:runUtsUnitTests --tests "io.ably.lib.uts.unit.realtime." + +# Proxy integration test (io.ably.lib.uts.integration.*) +./gradlew :uts:runUtsIntegrationTests --tests "io.ably.lib.uts.integration.proxy.realtime." ``` +(`./gradlew :uts:test` still runs all tiers — unit, standard, and proxy.) + Handle test failures using this decision tree (see [reference doc](https://github.com/ably/specification/blob/main/uts/docs/writing-derived-tests.md) for full detail): ``` @@ -379,7 +400,7 @@ assertEquals(40160, error.errorInfo.code) ### Deviations file -Append to `uts/src/test/kotlin/io/ably/lib/deviations.md`. Each entry needs: +Append to `uts/src/test/kotlin/io/ably/lib/uts/deviations.md`. Each entry needs: 1. The spec point (e.g. `RSA4c2`) 2. What the spec says 3. What the SDK does @@ -417,7 +438,7 @@ For each test case, verify: For any place where the generated test diverges from the spec pseudocode (adapted assertion, env-gated skip, or omitted step): - [ ] A `// DEVIATION:` comment explains why -- [ ] The deviation is recorded in `uts/src/test/kotlin/io/ably/lib/deviations.md` +- [ ] The deviation is recorded in `uts/src/test/kotlin/io/ably/lib/uts/deviations.md` If you find gaps during this review, fix them and re-run Steps 5–6 before finishing. @@ -439,11 +460,13 @@ Recognise them by: a reference to `create_proxy_session()`, proxy `rules`, `trig ### Infrastructure -Three helpers live in `uts/src/test/kotlin/io/ably/lib/test/helper/`. **Read them before translating a proxy spec** — they hold the exact method signatures. +Three helpers live under `uts/src/test/kotlin/io/ably/lib/uts/infra/integration/`. **Read them before translating a proxy spec** — they hold the exact method signatures. + +- **`ProxyManager`** (`infra/integration/proxy/ProxyManager.kt`, package `io.ably.lib.uts.infra.integration.proxy`) — downloads/starts the shared `uts-proxy` process and exposes the sandbox host. Call `ProxyManager.ensureProxy()` once per suite in setup. `ProxyManager.sandboxRealtimeHost` / `sandboxRestHost` are the upstream sandbox hosts (the default target of every session). +- **`ProxySession`** (`infra/integration/proxy/ProxySession.kt`, same package) — one programmable session wrapping the proxy control API; also defines the `connectThroughProxy` extension and the rule-builder helpers. +- **`SandboxApp`** (`infra/integration/SandboxApp.kt`, package `io.ably.lib.uts.infra.integration`) — provisions/deletes a sandbox test app from the shared `test-app-setup.json` in ably-common. `SandboxApp.create()` returns a `SandboxApp` with `appId`, `defaultKey`, and `keys` (`defaultKey` is a full-capability `appId.keyId:keySecret`); `app.delete()` tears it down. Provision in suite setup, delete in teardown. -- **`ProxyManager`** — downloads/starts the shared `uts-proxy` process and exposes the sandbox host. Call `ProxyManager.ensureProxy()` once per suite in setup. `ProxyManager.sandboxRealtimeHost` / `sandboxRestHost` are the upstream sandbox hosts (the default target of every session). -- **`ProxySession`** — one programmable session wrapping the proxy control API. -- **`SandboxApp`** — provisions/deletes a sandbox test app from the shared `test-app-setup.json` in ably-common. `SandboxApp.create()` returns a `SandboxApp` with `appId`, `defaultKey`, and `keys` (`defaultKey` is a full-capability `appId.keyId:keySecret`); `app.delete()` tears it down. Provision in suite setup, delete in teardown. +Import these into a proxy test from their packages, e.g. `io.ably.lib.uts.infra.integration.SandboxApp`, `io.ably.lib.uts.infra.integration.proxy.{ProxyManager, ProxySession, connectThroughProxy}`, plus `io.ably.lib.uts.infra.unit.TestRealtimeClient` and `io.ably.lib.uts.infra.{awaitState, pollUntil}`. `ensureProxy()`, the `ProxySession` methods, and the `SandboxApp` methods are all **`suspend`** functions. Per-test bodies use `runTest { }`; JUnit5 `@BeforeAll`/`@AfterAll` (with `@TestInstance(Lifecycle.PER_CLASS)`) wrap their suspend calls in `runBlocking { }`. diff --git a/uts/README.md b/uts/README.md index 952f35c67..615e1f936 100644 --- a/uts/README.md +++ b/uts/README.md @@ -81,9 +81,13 @@ tests you asked about sit in two different tiers. | Tier | Transport | Backend | Purpose | Example in this repo | |------|-----------|---------|---------|----------------------| | **Unit** | **Mocked** (`MockWebSocket`, `MockHttpClient`) | none | Client-side logic: state machines, request formation, response parsing, timer behaviour. Fast & deterministic. | `unit/realtime/ConnectionRecoveryTest.kt` | -| **Direct sandbox integration** | Real network | Real Ably sandbox | Happy-path interop: connect, publish, subscribe. No fault injection. | *(not in the two you asked about)* | +| **Direct sandbox integration** | Real network | Real Ably sandbox | Happy-path interop: connect, publish, subscribe. No fault injection. | `integration/standard//` *(tier exists; no tests yet)* | | **Proxy integration** | Real network **through a programmable proxy** | Real Ably sandbox | Fault behaviour: dropped connections, injected errors, timeouts, re-auth. | `integration/proxy/realtime/AuthReauthTest.kt` | +Each tier folder is further organised **by module** (`realtime`, `liveobjects`, …): `unit//`, +`integration/standard//`, and `integration/proxy//`. So a feature's tests sit together +by SDK area — the two example tests live at `unit/realtime/` and `integration/proxy/realtime/`. + Key principles (from [`integration-testing.md`](https://github.com/ably/specification/blob/main/uts/docs/integration-testing.md)): - **Integration tests do not replace unit tests.** A spec point covered by a proxy test should @@ -203,8 +207,10 @@ Takeaways: ### 4.2 Directory layout Everything lives under the `io.ably.lib.uts` package, split cleanly into **infrastructure** (`infra/`, -no `@Test`s) and the **tests** themselves (`unit/`, `integration/`), each mirroring the unit / -integration tiers: +no `@Test`s) and the **tests** themselves. Tests are organised **by tier, then by module**: `unit/` for +mocked-transport tests, and `integration/` for real-backend tests — the latter splitting again into +`standard/` (direct sandbox, happy-path) and `proxy/` (sandbox through the fault-injecting proxy). Under +each, a per-module folder (`realtime`, `liveobjects`, …) holds the actual test classes: ``` uts/src/test/kotlin/io/ably/lib/uts/ @@ -233,20 +239,27 @@ uts/src/test/kotlin/io/ably/lib/uts/ │ ├── ProxyManager.kt # downloads/launches the uts-proxy binary │ └── ProxySession.kt # proxy session: rules, actions, log + connectThroughProxy │ -├── unit/ # ── UNIT TESTS (mock transport) ── -│ └── realtime/ -│ └── ConnectionRecoveryTest.kt # ← the UNIT test (RTN16*) +├── unit/ # ── UNIT TESTS (mock transport) ── · per module +│ ├── realtime/ +│ │ └── ConnectionRecoveryTest.kt # ← the UNIT test (RTN16*) +│ └── liveobjects/ # (further modules as coverage grows) │ -└── integration/ # ── INTEGRATION TESTS (real backend) ── - └── proxy/ - └── realtime/ - └── AuthReauthTest.kt # ← the PROXY test (RTN22, RTC8a) +└── integration/ # ── INTEGRATION TESTS (real backend) ── · per module + ├── standard/ # direct sandbox: happy-path, no fault injection + │ ├── realtime/ + │ └── liveobjects/ + └── proxy/ # sandbox through the fault-injecting uts-proxy + ├── realtime/ + │ └── AuthReauthTest.kt # ← the PROXY test (RTN22, RTC8a) + └── liveobjects/ ``` -The mental model: **`infra/unit/` powers the unit tests, `infra/integration/` powers the integration -tests, and `infra/Utils.kt` serves both.** The `unit/` ↔ `infra/unit/` and `integration/` ↔ -`infra/integration/` pairing is what the `runUtsUnitTests` / `runUtsIntegrationTests` Gradle tasks -key off (§12). +The mental model: **`infra/unit/` powers the unit tests, `infra/integration/` powers both integration +kinds (`standard` + `proxy`), and `infra/Utils.kt` serves all of them.** Every tier is sub-divided **by +module** (`realtime`, `liveobjects`, …) so a feature's tests sit together regardless of SDK area. The +top-level `unit/` ↔ `infra/unit/` and `integration/` ↔ `infra/integration/` pairing is what the +`runUtsUnitTests` / `runUtsIntegrationTests` Gradle tasks key off (§12) — `runUtsIntegrationTests` +covers **both** `integration/standard/` and `integration/proxy/`. --- @@ -469,12 +482,17 @@ independent of the fault rules): - The Ktor client retries only **idempotent GETs** (never re-POSTs `/apps`, to avoid duplicate apps). +`SandboxApp` is the shared backbone of *both* integration kinds: **proxy** tests pair it with a +`ProxySession`, while **direct sandbox** tests (`integration/standard//`) use it alone — +connecting straight to `ProxyManager.sandboxRealtimeHost` / `sandboxRestHost` with no proxy and no +fault rules, for happy-path interop. + --- ## 8. Shared Async Helpers -`Utils.kt` provides the coroutine glue both tiers rely on. All three run on a **single-thread real -dispatcher** so their timeouts measure **wall-clock** time (not the virtual time of +`Utils.kt` provides the coroutine glue every tier relies on (unit, direct sandbox, and proxy). All +three run on a **single-thread real dispatcher** so their timeouts measure **wall-clock** time (not the virtual time of `kotlinx.coroutines.test`). The two state-waiters (`awaitState`/`awaitChannelState`) register their listener *before* checking current state, to avoid a check-then-register race; `pollUntil` has no listener — it re-evaluates the predicate every `interval` until it holds or the timeout fires. @@ -663,7 +681,8 @@ mirror `runLiveObjectsUnitTests` / `runLiveObjectsIntegrationTests` in the `live # Unit tests only — io.ably.lib.uts.unit.* (fast, no network). This is the PR gate. ./gradlew :uts:runUtsUnitTests -# Integration tests only — io.ably.lib.uts.integration.* (real sandbox; downloads/launches the proxy). +# Integration tests only — io.ably.lib.uts.integration.* (real sandbox; covers both +# integration/standard/ and integration/proxy/ — proxy tests also download/launch the uts-proxy). ./gradlew :uts:runUtsIntegrationTests # Everything (the default Test task still runs both): diff --git a/uts/index.html b/uts/index.html index 43be8b045..001a2718a 100644 --- a/uts/index.html +++ b/uts/index.html @@ -191,9 +191,10 @@

    2 The Three Test Tiers

    UTS divides tests into three tiers by what infrastructure they need and what confidence they give. This split is the key to understanding the whole uts/ module — the two example tests sit in two different tiers.

    Unit mocked

    Transport: mocked (MockWebSocket, MockHttpClient). Backend: none.
    Client-side logic: state machines, request formation, response parsing, timers. Fast & deterministic.
    → unit/realtime/ConnectionRecoveryTest.kt

    -

    Direct sandbox real net

    Transport: real. Backend: real Ably sandbox.
    Happy-path interop: connect, publish, subscribe. No fault injection.
    (not among the two example tests)

    +

    Direct sandbox real net

    Transport: real. Backend: real Ably sandbox.
    Happy-path interop: connect, publish, subscribe. No fault injection.
    → integration/standard/<module>/ (tier exists; no tests yet)

    Proxy integration faults

    Transport: real, through a programmable proxy. Backend: real sandbox.
    Fault behaviour: dropped connections, injected errors, timeouts, re-auth.
    → integration/proxy/realtime/AuthReauthTest.kt

    +

    Each tier folder is further organised by module (realtime, liveobjects, …): unit/<module>/, integration/standard/<module>/, and integration/proxy/<module>/. So a feature's tests sit together by SDK area — the two example tests live at unit/realtime/ and integration/proxy/realtime/.

    Key principles (from integration-testing.md):

    • Integration tests do not replace unit tests. A spec point covered by a proxy test should also have a unit test. The unit test proves client logic; the proxy test proves client & real server agree.
    • @@ -289,7 +290,7 @@

      4.1 uts/build.gradle.kts

    4.2 Directory layout

    -

    Everything lives under the io.ably.lib.uts package, split cleanly into infrastructure (infra/, no @Tests) and the tests themselves (unit/, integration/), each mirroring the unit / integration tiers:

    +

    Everything lives under the io.ably.lib.uts package, split cleanly into infrastructure (infra/, no @Tests) and the tests themselves. Tests are organised by tier, then by module: unit/ for mocked-transport tests, and integration/ for real-backend tests — the latter splitting again into standard/ (direct sandbox, happy-path) and proxy/ (sandbox through the fault-injecting proxy). Under each, a per-module folder (realtime, liveobjects, …) holds the actual test classes:

    uts/src/test/kotlin/io/ably/lib/uts/
     ├── deviations.md                        # the catalogue of SDK-vs-spec divergences
     │
    @@ -316,15 +317,20 @@ 

    4.2 Directory layout

    │ ├── ProxyManager.kt # downloads/launches the uts-proxy binary │ └── ProxySession.kt # proxy session: rules, actions, log + connectThroughProxy │ -├── unit/ # ── UNIT TESTS (mock transport) ── -│ └── realtime/ -│ └── ConnectionRecoveryTest.kt # ← the UNIT test (RTN16*) +├── unit/ # ── UNIT TESTS (mock transport) ── · per module +│ ├── realtime/ +│ │ └── ConnectionRecoveryTest.kt # ← the UNIT test (RTN16*) +│ └── liveobjects/ # (further modules as coverage grows) │ -└── integration/ # ── INTEGRATION TESTS (real backend) ── - └── proxy/ - └── realtime/ - └── AuthReauthTest.kt # ← the PROXY test (RTN22, RTC8a)
    -
    Mental model

    infra/unit/ powers the unit tests · infra/integration/ powers the integration tests · infra/Utils.kt serves both. The unit/infra/unit/ and integration/infra/integration/ pairing is what the runUtsUnitTests / runUtsIntegrationTests Gradle tasks key off (§12).

    +└── integration/ # ── INTEGRATION TESTS (real backend) ── · per module + ├── standard/ # direct sandbox: happy-path, no fault injection + │ ├── realtime/ + │ └── liveobjects/ + └── proxy/ # sandbox through the fault-injecting uts-proxy + ├── realtime/ + │ └── AuthReauthTest.kt # ← the PROXY test (RTN22, RTC8a) + └── liveobjects/

    +
    Mental model

    infra/unit/ powers the unit tests · infra/integration/ powers both integration kinds (standard + proxy) · infra/Utils.kt serves all of them. Every tier is sub-divided by module (realtime, liveobjects, …). The top-level unit/infra/unit/ and integration/infra/integration/ pairing is what the runUtsUnitTests / runUtsIntegrationTests Gradle tasks key off (§12) — runUtsIntegrationTests covers both integration/standard/ and integration/proxy/.

    @@ -463,6 +469,7 @@

    7.3 SandboxApp — a throwaway app on the real sandbox

  • SandboxApp.create() fetches the canonical test-app-setup.json from ably-common (specifically its post_apps sub-object), POSTs it to https://sandbox.realtime.ably-nonprod.net/apps, and exposes appId, defaultKey (full-capability appId.keyId:keySecret, from the keyStr field), and the full keys list.
  • delete() removes the app in teardown (best-effort — errors swallowed, sandbox apps auto-expire).
  • The Ktor client retries only idempotent GETs (never re-POSTs /apps, to avoid duplicate apps).
  • +
  • SandboxApp is the shared backbone of both integration kinds: proxy tests pair it with a ProxySession, while direct sandbox tests (integration/standard/<module>/) use it alone — connecting straight to ProxyManager.sandboxRealtimeHost / sandboxRestHost with no proxy and no fault rules, for happy-path interop.
  • The app is provisioned directly (not through the proxy), so it's independent of the fault rules under test.

    @@ -470,7 +477,7 @@

    7.3 SandboxApp — a throwaway app on the real sandbox

    8 Shared Async Helpers

    -

    Utils.kt provides the coroutine glue both tiers rely on. All three run on a single-thread real dispatcher so their timeouts measure wall-clock time (not the virtual time of kotlinx.coroutines.test). The two state-waiters register their listener before checking current state, to avoid a check-then-register race; pollUntil has no listener — it re-evaluates the predicate every interval.

    +

    Utils.kt provides the coroutine glue every tier relies on (unit, direct sandbox, and proxy). All three run on a single-thread real dispatcher so their timeouts measure wall-clock time (not the virtual time of kotlinx.coroutines.test). The two state-waiters register their listener before checking current state, to avoid a check-then-register race; pollUntil has no listener — it re-evaluates the predicate every interval.

    @@ -585,7 +592,8 @@

    12 How to Run the Tests

    # Unit tests only — io.ably.lib.uts.unit.*  (fast, no network). This is the PR gate.
     ./gradlew :uts:runUtsUnitTests
     
    -# Integration tests only — io.ably.lib.uts.integration.*  (real sandbox; downloads/launches the proxy).
    +# Integration tests only — io.ably.lib.uts.integration.*  (real sandbox; covers both
    +# integration/standard/ and integration/proxy/ — proxy tests also download/launch the uts-proxy).
     ./gradlew :uts:runUtsIntegrationTests
     
     # Everything (the default Test task still runs both):
    
    From 940b65f0f18d5b8826ef7bdd9fd522c211f78329 Mon Sep 17 00:00:00 2001
    From: sacOO7 
    Date: Thu, 25 Jun 2026 17:56:41 +0530
    Subject: [PATCH 33/40] feat(uts-to-kotlin): drive the skill from a module
     directory via a package mapping
    
    Rework the uts-to-kotlin skill to translate a whole UTS module at once
    instead of a single spec file:
    
    - Take a UTS module directory (e.g. .../specification/uts/objects) and
      validate it sits directly under uts/ with a standard tier structure.
    - Resolve the target ably-java package via uts-package-mapping.json (a new
      config file alongside the skill): a shared `testRoot` parent plus a
      `packages` table mapping each source module to its per-tier output dir
      (so objects -> liveobjects is explicit). Offer to create a mapping when
      one is missing.
    - Let the user pick a tier (unit / integration / proxy) and then translate
      all specs or a selected subset, looping each through the existing
      per-spec translation steps.
    
    Phase 1 (selection: Steps A-D) is new; Phase 2 (per-spec translation:
    Steps 1-7) keeps the existing rules, with Step 1/2 adjusted to consume the
    looped spec and the pre-resolved target.
    ---
     .claude/skills/uts-to-kotlin/SKILL.md         | 166 +++++++++++++-----
     .../uts-to-kotlin/uts-package-mapping.json    |  21 +++
     2 files changed, 145 insertions(+), 42 deletions(-)
     create mode 100644 .claude/skills/uts-to-kotlin/uts-package-mapping.json
    
    diff --git a/.claude/skills/uts-to-kotlin/SKILL.md b/.claude/skills/uts-to-kotlin/SKILL.md
    index 096ddd606..a76590340 100644
    --- a/.claude/skills/uts-to-kotlin/SKILL.md
    +++ b/.claude/skills/uts-to-kotlin/SKILL.md
    @@ -1,56 +1,145 @@
     ---
    -description: "Translate a UTS pseudocode test spec into Kotlin tests in the uts module. Usage: /uts-to-kotlin "
    +description: "Translate the UTS pseudocode test specs in a whole module directory into runnable Kotlin tests in the ably-java uts module. Takes a UTS module directory (e.g. .../specification/uts/objects), validates its structure, resolves the target ably-java module, lets you pick a tier (unit/integration/proxy) and which specs, then derives a Kotlin test per spec. Usage: /uts-to-kotlin "
     allowed-tools: Bash, Read, Edit, Write
     ---
     
    -Translate the UTS pseudocode test spec at `$ARGUMENTS` into a runnable Kotlin test in the `uts` module.
    +Translate the UTS pseudocode test specs under the **module directory** `$ARGUMENTS` into runnable Kotlin
    +tests in the ably-java `uts` module.
    +
    +`$ARGUMENTS` is a UTS *module* directory — a directory sitting directly under `.../specification/uts/`,
    +e.g. `/Users/sachinsh/ably-specification/specification/uts/objects`. Its name (`objects`, `realtime`,
    +`rest`, …) is the **source module**. A module directory holds many spec files, organised into tiers
    +(`unit/`, `integration/`, and `integration/proxy/`).
    +
    +The work happens in two phases:
    +
    +- **Phase 1 — Selection (Steps A–D below):** validate the directory, resolve the *target* ably-java
    +  module via `uts-package-mapping.json`, pick a tier, and pick which spec files to translate.
    +- **Phase 2 — Per-spec translation (Steps 1–7):** for each selected spec file, derive a Kotlin test.
     
     Reference: [Writing Derived Tests](https://raw.githubusercontent.com/ably/specification/refs/heads/main/uts/docs/writing-derived-tests.md)
     
     ---
     
    -## Step 0 — Validate arguments
    +# Phase 1 — Selection
    +
    +## Step A — Validate the module directory
     
    -**If `$ARGUMENTS` is empty or blank**, stop immediately and tell the user:
    +`$ARGUMENTS` must be a UTS **module directory** sitting directly under a `uts/` parent.
    +
    +**If `$ARGUMENTS` is empty or blank**, stop and tell the user:
     
     ```
    -Usage: /uts-to-kotlin 
    +Usage: /uts-to-kotlin 
     
     Example:
    -  /uts-to-kotlin lib/src/spec/uts/test/realtime/unit/connection/connection_state_machine_test.md
    +  /uts-to-kotlin /Users/sachinsh/ably-specification/specification/uts/objects
     
    -Please re-run the command with the path to a UTS pseudocode spec file.
    +Pass a UTS module directory (a directory directly under .../specification/uts/), not a single spec file.
     ```
     
    -Do not proceed to Step 1.
    -
    -**If `$ARGUMENTS` is provided but does not end in `.md`**, stop and tell the user:
    +Otherwise validate with these checks. Substitute the real path for `DIR=` (shell variables do **not**
    +persist between separate `Bash` calls, and `$ARGUMENTS` is a text placeholder, not a shell variable — so
    +set `DIR` literally each time you need it):
     
    +```bash
    +DIR="/absolute/path/to/the/module"               # the path passed in, trailing slash removed
    +# 1. Must be a directory directly under a `uts/` parent: .../uts/
    +[[ "$DIR" =~ /uts/[^/]+$ ]] || echo "NOT_A_UTS_MODULE_PATH"
    +# 2. Must exist as a directory
    +test -d "$DIR" || echo "DIR_NOT_FOUND"
    +# 3. Standard structure: at least one recognised tier directory
    +{ test -d "$DIR/unit" || test -d "$DIR/integration"; } || echo "NO_TIER_DIRS"
    +echo "MODULE=$(basename "$DIR")"
     ```
    -Error: "" does not look like a spec file path (expected a .md file).
     
    -Usage: /uts-to-kotlin 
    -```
    +- If `NOT_A_UTS_MODULE_PATH` → the path isn't `.../uts/`. Tell the user the path must point at a
    +  module directory directly under `uts/` (e.g. `.../specification/uts/objects`) and stop.
    +- If `DIR_NOT_FOUND` → tell the user the directory doesn't exist and stop.
    +- If `NO_TIER_DIRS` → the directory has no `unit/` or `integration/` sub-directory, so it isn't a valid UTS
    +  module. Tell the user and stop.
     
    -Do not proceed to Step 1.
    +The **source module** is the directory's base name (`MODULE=` above) — `objects`, `realtime`, `rest`, …
     
    -**If `$ARGUMENTS` ends in `.md` but the file does not exist** (check with `test -f "$ARGUMENTS"`), stop and tell the user:
    +Only continue once all three checks pass.
     
    -```
    -Error: file not found: ""
    +## Step B — Resolve the target ably-java module
    +
    +Spec modules don't always share a name with their ably-java counterpart (e.g. `objects` → `liveobjects`),
    +so the mapping is explicit. Read `uts-package-mapping.json`, which sits alongside this skill at
    +`.claude/skills/uts-to-kotlin/uts-package-mapping.json`. The file has a single shared parent — `testRoot`
    +(the directory, from the ably-java repo root, that every target lives under) — and a `packages` table whose
    +keys are source module names. Each tier value is the output directory **relative to `testRoot`**:
     
    -Check the path and try again.
    +```json
    +"testRoot": "uts/src/test/kotlin/io/ably/lib/uts",
    +"packages": {
    +  "objects": {
    +    "unit": "unit/liveobjects",
    +    "integration": "integration/standard/liveobjects",
    +    "proxy": "integration/proxy/liveobjects"
    +  }
    +}
     ```
     
    -Do not proceed to Step 1.
    +Resolve a tier to its concrete target like so:
    +- **Output directory** = `testRoot` + `/` + the tier entry (e.g. `uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects`).
    +- **Kotlin package** = that path's segment **after `src/test/kotlin/`** with `/` replaced by `.` (e.g. `io.ably.lib.uts.unit.liveobjects`).
    +
    +Then:
    +- **If the source module has an entry**, show its three resolved target dirs and ask the user to confirm it,
    +  or to pick a different existing module instead.
    +- **If it has no entry**, tell the user there's no mapping yet and offer to create one. Ask for the target
    +  module base name (default to the source name; suggest a rename only when the SDK uses different
    +  terminology, e.g. `objects` → `liveobjects`). Then add a new entry to `uts-package-mapping.json` with the
    +  three `testRoot`-relative paths — `unit/`, `integration/standard/`, and
    +  `integration/proxy/` — and save the file before continuing.
    +
    +## Step C — Choose the tier
     
    -Only continue to Step 1 once the file is confirmed to exist.
    +Offer the tiers that actually exist in the source module, and map each to its source spec directory and the
    +target output directory from Step B:
    +
    +| Tier | Source spec directory | Target (mapping entry, joined onto `testRoot`) | Per-spec translation flow |
    +|---|---|---|---|
    +| **unit** | `/unit/` | mapping `unit` (e.g. `unit/liveobjects`) | mocked transport — Steps 1–7 below |
    +| **integration** (direct sandbox) | `/integration/` *(excluding `proxy/` and `helpers/`)* | mapping `integration` (e.g. `integration/standard/liveobjects`) | real sandbox, no faults — see **Proxy integration tests** but drop the `ProxySession`/`connectThroughProxy` wiring |
    +| **proxy** | `/integration/proxy/` | mapping `proxy` (e.g. `integration/proxy/liveobjects`) | real sandbox + fault injection — see **Proxy integration tests** |
    +
    +The tier you pick here fixes the target directory/package and which translation flow Phase 2 uses — you do
    +**not** re-detect it per spec.
    +
    +## Step D — Choose which specs to translate
    +
    +List the candidate spec files in the chosen tier's source directory (recurse, but **exclude** any
    +`helpers/` directory and non-spec docs like `PLAN.md`, `README.md`, or `*_SUMMARY.md`). Substitute the
    +real module path for `DIR` again:
    +
    +```bash
    +DIR="/absolute/path/to/the/module"
    +# unit
    +find "$DIR/unit" -name '*.md' -not -path '*/helpers/*' | sort
    +# integration (direct sandbox) — exclude the proxy subtree
    +find "$DIR/integration" -name '*.md' -not -path '*/proxy/*' -not -path '*/helpers/*' | sort
    +# proxy
    +find "$DIR/integration/proxy" -name '*.md' -not -path '*/helpers/*' | sort
    +```
    +
    +Present the list and ask the user whether to **translate all** of them or **select specific** files. Then,
    +for each selected spec, run Phase 2 (Steps 1–7).
     
     ---
     
    +# Phase 2 — Per-spec translation
    +
    +Run this for **each** spec file selected in Step D. When translating several specs, do Steps 1–4 (generate
    +the file) for every spec first, then run Step 5 (compile) once for the whole module, then Steps 6–7 per
    +file — compiling once is faster than per-file and surfaces cross-file issues together. For a single spec,
    +just go through Steps 1–7 in order.
    +
     ## Step 1 — Read the spec
     
    -Read the file at `$ARGUMENTS`. Identify:
    +Read the current spec file (the one being translated from the Step D selection). Identify:
     - All test cases — each has a structured ID like `realtime/unit/RSA4c2/callback-error-connecting-disconnected-0` and a description
     - The protocol used (WebSocket for Realtime, HTTP for REST)
     - Any timer usage (`enable_fake_timers`, `ADVANCE_TIME`)
    @@ -59,30 +148,23 @@ Read the file at `$ARGUMENTS`. Identify:
     
     ## Step 2 — Determine output path and package
     
    -Map the spec path to a test path. **Tests are organised tier-first** (`unit/` vs `integration/standard/`
    -vs `integration/proxy/`), then **by module** (`realtime`, `rest`, `liveobjects`, …), all under the
    -`io.ably.lib.uts` package. (`…/uts/` below is shorthand for `uts/src/test/kotlin/io/ably/lib/uts/`.)
    -
    -| Spec location | Test location | Package |
    -|---|---|---|
    -| `...//unit/<…>/.md` | `…/uts/unit//Test.kt` | `io.ably.lib.uts.unit.` |
    -| `...//integration/<…>/.md` (direct sandbox) | `…/uts/integration/standard//Test.kt` | `io.ably.lib.uts.integration.standard.` |
    -| `...//integration/<…>/.md` (proxy) | `…/uts/integration/proxy//Test.kt` | `io.ably.lib.uts.integration.proxy.` |
    -
    -`` is the SDK area — `realtime`, `rest`, `liveobjects`, … (existing folders: `unit/realtime/`,
    -`unit/liveobjects/`). The spec's own `` grouping (e.g. `connection/`) is **not** carried into a
    -sub-package — tests sit directly under the tier/module folder (e.g. `connection_recovery_test.md` →
    -`…/uts/unit/realtime/ConnectionRecoveryTest.kt`).
    -
    -Class name: take the file name, strip `_test` suffix, convert `snake_case` → `PascalCase`, append `Test`.
    +The target directory and package are already fixed by the tier chosen in Step C and the mapping resolved in
    +Step B — you do not re-derive them from the spec path. `` is `testRoot` + the tier entry (e.g.
    +`uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects`) and `` is its dotted form after
    +`src/test/kotlin/` (e.g. `io.ably.lib.uts.unit.liveobjects`).
     
    -**Integration specs come in two kinds:**
    -- Those that **inject faults** (reference `create_proxy_session()`, proxy `rules`, `trigger_action`, `get_log`, or `uts/test/realtime/integration/helpers/proxy.md`) are **proxy** tests under `integration/proxy//` — follow the **Proxy integration tests** section at the end of this skill instead of the unit-test rules below.
    -- Those that exercise only **happy-path interop** against the real sandbox (no fault injection) are **direct sandbox** tests under `integration/standard//`. They use `SandboxApp` alone (no `ProxySession`), connecting straight to `ProxyManager.sandboxRealtimeHost` / `sandboxRestHost`. *(No example exists yet — model the suite setup/teardown on the proxy section but drop the `ProxySession`/`connectThroughProxy` wiring.)*
    +Only the **class name** comes from the spec file: take its file name, strip a `_test` suffix if present,
    +convert `snake_case` → `PascalCase`, and append `Test`. Examples: `objects_batch_test.md` →
    +`ObjectsBatchTest`; `live_counter_api.md` → `LiveCounterApiTest`; `connection_recovery_test.md` →
    +`ConnectionRecoveryTest`.
     
    -Example: `connection_state_machine_test.md` → `ConnectionStateMachineTest`
    +The spec's own `` grouping (e.g. `connection/`, `channels/`) is **not** carried into a sub-package —
    +every test sits directly in ``. Output file: `/.kt`, with
    +`package ` at the top.
     
    -Package: derived from the output path under `kotlin/`.
    +The chosen tier also fixes the translation flow: **unit** → the rules in Steps 3–4 below; **integration**
    +(direct sandbox) and **proxy** → the **Proxy integration tests** section (direct sandbox drops the
    +`ProxySession`/`connectThroughProxy` wiring; see Step C).
     
     ---
     
    diff --git a/.claude/skills/uts-to-kotlin/uts-package-mapping.json b/.claude/skills/uts-to-kotlin/uts-package-mapping.json
    new file mode 100644
    index 000000000..fce1317db
    --- /dev/null
    +++ b/.claude/skills/uts-to-kotlin/uts-package-mapping.json
    @@ -0,0 +1,21 @@
    +{
    +  "_comment": "Maps each UTS spec module (a dir under specification/uts/) to its target test packages. Output dir = testRoot + '/' + tier entry; Kotlin package = that path after 'src/test/kotlin/' with '/' -> '.'. Used by the uts-to-kotlin skill.",
    +  "testRoot": "uts/src/test/kotlin/io/ably/lib/uts",
    +  "packages": {
    +    "realtime": {
    +      "unit": "unit/realtime",
    +      "integration": "integration/standard/realtime",
    +      "proxy": "integration/proxy/realtime"
    +    },
    +    "objects": {
    +      "unit": "unit/liveobjects",
    +      "integration": "integration/standard/liveobjects",
    +      "proxy": "integration/proxy/liveobjects"
    +    },
    +    "rest": {
    +      "unit": "unit/rest",
    +      "integration": "integration/standard/rest",
    +      "proxy": "integration/proxy/rest"
    +    }
    +  }
    +}
    
    From 0aaec3981b84291c21d773b60897058db18ceb1d Mon Sep 17 00:00:00 2001
    From: sacOO7 
    Date: Thu, 25 Jun 2026 22:27:20 +0530
    Subject: [PATCH 34/40] feat(uts-to-kotlin): deterministic selection via
     resolver script + evaluate mode
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Make the skill's selection phase deterministic and add an explicit
    translate-vs-evaluate choice:
    
    - Add scripts/resolve_uts.py — a bundled resolver that validates the module
      directory, reads uts-package-mapping.json, and emits JSON with, per tier,
      the target dir, Kotlin package, and the candidate specs with derived class
      names. This replaces the per-run hand-work (regex validation, path joins,
      snake_case->PascalCase) that the model previously improvised, so Phase 1 is
      byte-for-byte deterministic. Exclusions are checked relative to the tier
      base (robust to the checkout location), and --create guards the target name.
    - Rewrite Phase 1 (Steps A-E) around the resolver: resolve, confirm/create
      mapping, choose tier, choose specs, choose translate-only vs evaluate.
    - Gate Step 6 (run/fix) behind evaluate mode per writing-derived-tests.md's
      Translation (always) vs Evaluation (only when an implementation exists)
      split; translate-only stops after compile + review.
    - Make the reference fetch mandatory (WebFetch added to allowed-tools).
    - Fix the file template to use the resolver's package/className (no hardcoded
      realtime, no double Test suffix) and the spec's full @UTS id; correct stale
      uts/test/... proxy doc paths.
    ---
     .claude/skills/uts-to-kotlin/SKILL.md         | 233 ++++++++----------
     .../uts-to-kotlin/scripts/resolve_uts.py      | 165 +++++++++++++
     2 files changed, 270 insertions(+), 128 deletions(-)
     create mode 100644 .claude/skills/uts-to-kotlin/scripts/resolve_uts.py
    
    diff --git a/.claude/skills/uts-to-kotlin/SKILL.md b/.claude/skills/uts-to-kotlin/SKILL.md
    index a76590340..80bfb513e 100644
    --- a/.claude/skills/uts-to-kotlin/SKILL.md
    +++ b/.claude/skills/uts-to-kotlin/SKILL.md
    @@ -1,6 +1,6 @@
     ---
     description: "Translate the UTS pseudocode test specs in a whole module directory into runnable Kotlin tests in the ably-java uts module. Takes a UTS module directory (e.g. .../specification/uts/objects), validates its structure, resolves the target ably-java module, lets you pick a tier (unit/integration/proxy) and which specs, then derives a Kotlin test per spec. Usage: /uts-to-kotlin "
    -allowed-tools: Bash, Read, Edit, Write
    +allowed-tools: Bash, Read, Edit, Write, WebFetch
     ---
     
     Translate the UTS pseudocode test specs under the **module directory** `$ARGUMENTS` into runnable Kotlin
    @@ -13,129 +13,105 @@ e.g. `/Users/sachinsh/ably-specification/specification/uts/objects`. Its name (`
     
     The work happens in two phases:
     
    -- **Phase 1 — Selection (Steps A–D below):** validate the directory, resolve the *target* ably-java
    -  module via `uts-package-mapping.json`, pick a tier, and pick which spec files to translate.
    +- **Phase 1 — Selection (Steps A–E below):** a bundled resolver script validates the directory and works
    +  out the target ably-java package, the spec files, and their class names; you then pick a tier, pick which
    +  specs, and choose whether to also evaluate.
     - **Phase 2 — Per-spec translation (Steps 1–7):** for each selected spec file, derive a Kotlin test.
     
    -Reference: [Writing Derived Tests](https://raw.githubusercontent.com/ably/specification/refs/heads/main/uts/docs/writing-derived-tests.md)
    +## Required reading — fetch first
    +
    +Always fetch [writing-derived-tests.md](https://raw.githubusercontent.com/ably/specification/refs/heads/main/uts/docs/writing-derived-tests.md) first (once per run) — don't rely on memory or the inlined summaries; the manual is updated over time.
     
     ---
     
     # Phase 1 — Selection
     
    -## Step A — Validate the module directory
    -
    -`$ARGUMENTS` must be a UTS **module directory** sitting directly under a `uts/` parent.
    -
    -**If `$ARGUMENTS` is empty or blank**, stop and tell the user:
    -
    -```
    -Usage: /uts-to-kotlin 
    +Path validation, the package mapping, spec discovery, and class-name derivation are all mechanical, so a
    +bundled script does them — that keeps selection byte-for-byte deterministic instead of relying on the model
    +to re-eyeball regexes, join paths, and hand-convert `snake_case` → `PascalCase` each run.
     
    -Example:
    -  /uts-to-kotlin /Users/sachinsh/ably-specification/specification/uts/objects
    +> **If `$ARGUMENTS` is empty or blank**, stop and show: `Usage: /uts-to-kotlin `
    +> — with the example `/uts-to-kotlin /Users/sachinsh/ably-specification/specification/uts/objects`.
     
    -Pass a UTS module directory (a directory directly under .../specification/uts/), not a single spec file.
    -```
    +## Step A — Resolve the module
     
    -Otherwise validate with these checks. Substitute the real path for `DIR=` (shell variables do **not**
    -persist between separate `Bash` calls, and `$ARGUMENTS` is a text placeholder, not a shell variable — so
    -set `DIR` literally each time you need it):
    +Run the resolver on the directory passed in (substitute the real path; the script path is relative to the
    +ably-java repo root):
     
     ```bash
    -DIR="/absolute/path/to/the/module"               # the path passed in, trailing slash removed
    -# 1. Must be a directory directly under a `uts/` parent: .../uts/
    -[[ "$DIR" =~ /uts/[^/]+$ ]] || echo "NOT_A_UTS_MODULE_PATH"
    -# 2. Must exist as a directory
    -test -d "$DIR" || echo "DIR_NOT_FOUND"
    -# 3. Standard structure: at least one recognised tier directory
    -{ test -d "$DIR/unit" || test -d "$DIR/integration"; } || echo "NO_TIER_DIRS"
    -echo "MODULE=$(basename "$DIR")"
    +python3 .claude/skills/uts-to-kotlin/scripts/resolve_uts.py ""
     ```
     
    -- If `NOT_A_UTS_MODULE_PATH` → the path isn't `.../uts/`. Tell the user the path must point at a
    -  module directory directly under `uts/` (e.g. `.../specification/uts/objects`) and stop.
    -- If `DIR_NOT_FOUND` → tell the user the directory doesn't exist and stop.
    -- If `NO_TIER_DIRS` → the directory has no `unit/` or `integration/` sub-directory, so it isn't a valid UTS
    -  module. Tell the user and stop.
    -
    -The **source module** is the directory's base name (`MODULE=` above) — `objects`, `realtime`, `rest`, …
    -
    -Only continue once all three checks pass.
    +It prints one JSON object. **If `ok` is `false`, relay `message` to the user and stop** — error codes:
    +`NOT_A_UTS_MODULE_PATH` (not a `.../uts/` directory), `DIR_NOT_FOUND`, `NO_TIER_DIRS` (no `unit/`
    +or `integration/`). On success it gives `sourceModule`, `mapped`, `testRoot`, and a `tiers` object with one
    +entry per tier (`unit` / `integration` / `proxy`), each carrying `present`, `sourceDir`, `targetDir`,
    +`package`, and `specs` (a list of `{file, className}`). Everything downstream reads from this output — treat
    +it as the single source of truth and don't recompute paths or names by hand.
     
    -## Step B — Resolve the target ably-java module
    +## Step B — Confirm or create the target mapping
     
    -Spec modules don't always share a name with their ably-java counterpart (e.g. `objects` → `liveobjects`),
    -so the mapping is explicit. Read `uts-package-mapping.json`, which sits alongside this skill at
    -`.claude/skills/uts-to-kotlin/uts-package-mapping.json`. The file has a single shared parent — `testRoot`
    -(the directory, from the ably-java repo root, that every target lives under) — and a `packages` table whose
    -keys are source module names. Each tier value is the output directory **relative to `testRoot`**:
    +The target dirs come from `uts-package-mapping.json` (alongside this skill); spec and ably-java module names
    +don't always match (e.g. `objects` → `liveobjects`), which is why it's explicit.
     
    -```json
    -"testRoot": "uts/src/test/kotlin/io/ably/lib/uts",
    -"packages": {
    -  "objects": {
    -    "unit": "unit/liveobjects",
    -    "integration": "integration/standard/liveobjects",
    -    "proxy": "integration/proxy/liveobjects"
    -  }
    -}
    -```
    +- **If `mapped` is `true`**: show the resolved `targetDir` for each present tier and ask the user to confirm.
    +  If they say the mapping is wrong, ask for the correct ably-java module base name and re-run with `--create`
    +  (below) to overwrite the entry, then re-resolve.
    +- **If `mapped` is `false`**: there's no mapping for `sourceModule` yet. Ask for the target ably-java module
    +  base name (default to `sourceModule`; suggest a rename only when the SDK uses different terminology, e.g.
    +  `objects` → `liveobjects`), then create it deterministically and re-resolve:
     
    -Resolve a tier to its concrete target like so:
    -- **Output directory** = `testRoot` + `/` + the tier entry (e.g. `uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects`).
    -- **Kotlin package** = that path's segment **after `src/test/kotlin/`** with `/` replaced by `.` (e.g. `io.ably.lib.uts.unit.liveobjects`).
    +  ```bash
    +  python3 .claude/skills/uts-to-kotlin/scripts/resolve_uts.py "" --create 
    +  ```
     
    -Then:
    -- **If the source module has an entry**, show its three resolved target dirs and ask the user to confirm it,
    -  or to pick a different existing module instead.
    -- **If it has no entry**, tell the user there's no mapping yet and offer to create one. Ask for the target
    -  module base name (default to the source name; suggest a rename only when the SDK uses different
    -  terminology, e.g. `objects` → `liveobjects`). Then add a new entry to `uts-package-mapping.json` with the
    -  three `testRoot`-relative paths — `unit/`, `integration/standard/`, and
    -  `integration/proxy/` — and save the file before continuing.
    +  This adds `unit/`, `integration/standard/`, and `integration/proxy/` under
    +  `packages` and re-prints the resolved output. (`` must be a simple module base name — letters,
    +  digits, underscore; the script returns `BAD_TARGET_NAME` otherwise, so just ask again.)
     
     ## Step C — Choose the tier
     
    -Offer the tiers that actually exist in the source module, and map each to its source spec directory and the
    -target output directory from Step B:
    +Offer the tiers whose `present` is `true`. The chosen tier fixes the `targetDir`, `package`, and `specs`
    +(from Step A) **and** the translation flow Phase 2 uses — don't re-detect any of it per spec:
     
    -| Tier | Source spec directory | Target (mapping entry, joined onto `testRoot`) | Per-spec translation flow |
    -|---|---|---|---|
    -| **unit** | `/unit/` | mapping `unit` (e.g. `unit/liveobjects`) | mocked transport — Steps 1–7 below |
    -| **integration** (direct sandbox) | `/integration/` *(excluding `proxy/` and `helpers/`)* | mapping `integration` (e.g. `integration/standard/liveobjects`) | real sandbox, no faults — see **Proxy integration tests** but drop the `ProxySession`/`connectThroughProxy` wiring |
    -| **proxy** | `/integration/proxy/` | mapping `proxy` (e.g. `integration/proxy/liveobjects`) | real sandbox + fault injection — see **Proxy integration tests** |
    -
    -The tier you pick here fixes the target directory/package and which translation flow Phase 2 uses — you do
    -**not** re-detect it per spec.
    +| Tier | Translation flow |
    +|---|---|
    +| **unit** | mocked transport — Steps 3–4 below |
    +| **integration** (direct sandbox) | real sandbox, no faults — **Proxy integration tests** section, but drop the `ProxySession`/`connectThroughProxy` wiring |
    +| **proxy** | real sandbox + fault injection — **Proxy integration tests** section |
     
     ## Step D — Choose which specs to translate
     
    -List the candidate spec files in the chosen tier's source directory (recurse, but **exclude** any
    -`helpers/` directory and non-spec docs like `PLAN.md`, `README.md`, or `*_SUMMARY.md`). Substitute the
    -real module path for `DIR` again:
    +The chosen tier's `specs` list (from Step A) is the candidate set — each entry already has its source `file`
    +and derived `className`. Present it and ask whether to translate **all** of them or a **selected subset**.
    +Then continue to Step E.
     
    -```bash
    -DIR="/absolute/path/to/the/module"
    -# unit
    -find "$DIR/unit" -name '*.md' -not -path '*/helpers/*' | sort
    -# integration (direct sandbox) — exclude the proxy subtree
    -find "$DIR/integration" -name '*.md' -not -path '*/proxy/*' -not -path '*/helpers/*' | sort
    -# proxy
    -find "$DIR/integration/proxy" -name '*.md' -not -path '*/helpers/*' | sort
    -```
    +## Step E — Translate only, or also evaluate?
    +
    +`writing-derived-tests.md` splits the work into **Translation** (always) and **Evaluation** (only
    +meaningful once the SDK implementation for this module exists). Ask the user which they want, and carry the
    +answer into Phase 2:
     
    -Present the list and ask the user whether to **translate all** of them or **select specific** files. Then,
    -for each selected spec, run Phase 2 (Steps 1–7).
    +- **Translate only** — generate each test and make it **compile** (Steps 1–5 and the Step 7 review). Don't
    +  run the tests. Use this when the SDK feature isn't implemented yet, so there's nothing to run against.
    +- **Translate and evaluate** — all of the above **plus** running the tests and **fixing until they pass**
    +  (Step 6): work the decision tree, and where the SDK genuinely diverges, gate/adapt the assertion and
    +  record a deviation. Use this when the implementation exists.
    +
    +If you can't tell whether the implementation exists, ask the user rather than guessing.
     
     ---
     
     # Phase 2 — Per-spec translation
     
    -Run this for **each** spec file selected in Step D. When translating several specs, do Steps 1–4 (generate
    -the file) for every spec first, then run Step 5 (compile) once for the whole module, then Steps 6–7 per
    -file — compiling once is faster than per-file and surfaces cross-file issues together. For a single spec,
    -just go through Steps 1–7 in order.
    +Run this for **each** spec file selected in Step D. **Step 6 only applies in "translate and evaluate" mode
    +(Step E)** — in "translate only" mode, stop after compiling (Step 5) and reviewing (Step 7), and skip
    +Step 6 entirely.
    +
    +When translating several specs, do Steps 1–4 (generate the file) for every spec first, then run Step 5
    +(compile) once for the whole module, then per file run Step 6 (only if evaluating) and the Step 7 review —
    +compiling once is faster than per-file and surfaces cross-file issues together. For a single spec, just go
    +through the steps in order.
     
     ## Step 1 — Read the spec
     
    @@ -146,25 +122,17 @@ Read the current spec file (the one being translated from the Step D selection).
     
     ---
     
    -## Step 2 — Determine output path and package
    -
    -The target directory and package are already fixed by the tier chosen in Step C and the mapping resolved in
    -Step B — you do not re-derive them from the spec path. `` is `testRoot` + the tier entry (e.g.
    -`uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects`) and `` is its dotted form after
    -`src/test/kotlin/` (e.g. `io.ably.lib.uts.unit.liveobjects`).
    +## Step 2 — Output path and package
     
    -Only the **class name** comes from the spec file: take its file name, strip a `_test` suffix if present,
    -convert `snake_case` → `PascalCase`, and append `Test`. Examples: `objects_batch_test.md` →
    -`ObjectsBatchTest`; `live_counter_api.md` → `LiveCounterApiTest`; `connection_recovery_test.md` →
    -`ConnectionRecoveryTest`.
    +Don't derive anything here — the resolver (Step A) already produced it. For the chosen tier use its
    +`targetDir` and `package`, and for the spec being translated use its `className` from that tier's `specs`
    +list. Write the test to `/.kt` with `package ` at the top.
     
    -The spec's own `` grouping (e.g. `connection/`, `channels/`) is **not** carried into a sub-package —
    -every test sits directly in ``. Output file: `/.kt`, with
    -`package ` at the top.
    -
    -The chosen tier also fixes the translation flow: **unit** → the rules in Steps 3–4 below; **integration**
    -(direct sandbox) and **proxy** → the **Proxy integration tests** section (direct sandbox drops the
    -`ProxySession`/`connectThroughProxy` wiring; see Step C).
    +The spec's own `` grouping (e.g. `connection/`, `channels/`) is **not** reflected in the output — every
    +test sits directly in `targetDir` (the resolver flattens it). The chosen tier also fixes the translation
    +flow: **unit** → the rules in Steps 3–4 below; **integration** (direct sandbox) and **proxy** → the **Proxy
    +integration tests** section (direct sandbox drops the `ProxySession`/`connectThroughProxy` wiring; see
    +Step C).
     
     ---
     
    @@ -346,8 +314,10 @@ yield()
     
     ### Test naming and annotation
     
    -- KDoc comment immediately above `@Test` using `/** @UTS  */` format
    -- Method name: backtick string `` ` - ` ``
    +- KDoc comment immediately above `@Test` using `/** @UTS  */` — copy the spec's **full** structured
    +  id verbatim (e.g. `realtime/unit/RTN4a/some-description-0`; for the objects module it would start
    +  `objects/unit/…`). Don't hand-build the prefix — use what the spec file declares.
    +- Method name: backtick string `` ` - ` `` — the spec point (e.g. `RTN4a`) plus a short description.
     - Use `runTest { }` from `kotlinx.coroutines.test` for all async tests
     
     ```kotlin
    @@ -363,7 +333,7 @@ fun `RTN4a - description of what is being tested`() = runTest {
     ### File template
     
     ```kotlin
    -package io.ably.lib.uts.unit.realtime          // io.ably.lib.uts.unit. — realtime, rest, liveobjects, …
    +package                               // the resolver's package for the chosen tier (Step 2)
     
     import io.ably.lib.uts.infra.unit.*            // TestRealtimeClient/TestRestClient, MockWebSocket, MockHttpClient, FakeClock, CONNECTED_MESSAGE, ConnectionDetails { } builder
     import io.ably.lib.uts.infra.awaitState
    @@ -378,13 +348,13 @@ import kotlinx.coroutines.test.runTest
     import kotlin.test.*
     import kotlin.time.Duration.Companion.seconds  // if using Duration literals
     
    -class Test {
    +class  {
     
         /**
    -     * @UTS realtime/unit//
    +     * @UTS 
          */
         @Test
    -    fun ` - `() = runTest {
    +    fun ` - `() = runTest {
             val mock = MockWebSocket {
                 onConnectionAttempt = { conn ->
                     conn.respondWithSuccess(ProtocolMessage().apply {
    @@ -427,21 +397,26 @@ Fix any compilation errors and recompile until clean. Common issues:
     
     ---
     
    -## Step 6 — Run tests
    +## Step 6 — Run tests *(evaluate mode only)*
    +
    +Skip this whole step in "translate only" mode. In "translate and evaluate" mode, run the test and **keep
    +fixing until it passes** — either the spec-correct assertion passes, or it's deliberately gated/adapted as a
    +documented deviation (below). A red test is never an acceptable end state here.
     
    -Use the per-tier task that matches what you generated (both are registered in `uts/build.gradle.kts`):
    +Use the per-tier task that matches the chosen tier (both are registered in `uts/build.gradle.kts`), and the
    +resolver's `package` + the spec's `className` for the `--tests` filter:
     
     ```bash
    -# Unit test  (io.ably.lib.uts.unit.*)
    -./gradlew :uts:runUtsUnitTests --tests "io.ably.lib.uts.unit.realtime."
    +# unit tier            → io.ably.lib.uts.unit.*
    +./gradlew :uts:runUtsUnitTests --tests "."
     
    -# Proxy integration test  (io.ably.lib.uts.integration.*)
    -./gradlew :uts:runUtsIntegrationTests --tests "io.ably.lib.uts.integration.proxy.realtime."
    +# integration / proxy  → io.ably.lib.uts.integration.*
    +./gradlew :uts:runUtsIntegrationTests --tests "."
     ```
     
     (`./gradlew :uts:test` still runs all tiers — unit, standard, and proxy.)
     
    -Handle test failures using this decision tree (see [reference doc](https://github.com/ably/specification/blob/main/uts/docs/writing-derived-tests.md) for full detail):
    +Handle test failures using this decision tree (the **Required reading** doc you fetched up front has the full detail):
     
     ```
     Test fails
    @@ -516,13 +491,15 @@ For each test case, verify:
     - [ ] Timer setup (`enableFakeTimers`, `fakeClock.advance(...)`) matches every `enable_fake_timers` / `ADVANCE_TIME` in the spec
     - [ ] Channel operations (attach, detach, publish) are performed in the order the spec requires
     
    -### Deviation honesty
    +### Deviation honesty *(evaluate mode)*
     
    -For any place where the generated test diverges from the spec pseudocode (adapted assertion, env-gated skip, or omitted step):
    +Deviations are discovered by running, so this check applies in evaluate mode. For any place where the
    +generated test diverges from the spec pseudocode (adapted assertion, env-gated skip, or omitted step):
     - [ ] A `// DEVIATION:` comment explains why
     - [ ] The deviation is recorded in `uts/src/test/kotlin/io/ably/lib/uts/deviations.md`
     
    -If you find gaps during this review, fix them and re-run Steps 5–6 before finishing.
    +If you find gaps during this review, fix them and re-run Step 5 (compile) — and, in evaluate mode, Step 6 —
    +before finishing.
     
     ---
     
    @@ -530,7 +507,7 @@ If you find gaps during this review, fix them and re-run Steps 5–6 before fini
     
     Some specs are **integration tests** that exercise fault-handling behaviour against the **real Ably sandbox** instead of a mocked transport. They route the SDK through the [`ably/uts-proxy`](https://github.com/ably/uts-proxy) — a programmable HTTP/WebSocket proxy that forwards traffic transparently by default but can inject faults (dropped connections, modified/injected/delayed frames, error responses) via rules.
     
    -Recognise them by: a reference to `create_proxy_session()`, proxy `rules`, `trigger_action`, `get_log`, or a pointer to `uts/test/realtime/integration/helpers/proxy.md`.
    +Recognise them by: a reference to `create_proxy_session()`, proxy `rules`, `trigger_action`, `get_log`, or a pointer to `uts/realtime/integration/helpers/proxy.md`.
     
     ### When proxy tests are the right tool
     
    @@ -560,9 +537,9 @@ Give every proxy integration test class this KDoc:
     /**
      * Proxy integration test against Ably Sandbox endpoint.
      *
    - * Uses the programmable proxy (`uts/test/proxy/`) to inject transport-level faults while the
    + * Uses the programmable uts-proxy to inject transport-level faults while the
      * SDK communicates with the real Ably backend. See
    - * `uts/test/realtime/integration/helpers/proxy.md` for proxy infrastructure details.
    + * `uts/realtime/integration/helpers/proxy.md` for proxy infrastructure details.
      */
     ```
     
    @@ -694,4 +671,4 @@ assertNotNull(queryParams["resume"])
     5. Use generous timeouts (10–30s) — real network is involved: `awaitState(client, ConnectionState.connected, 15.seconds)`.
     6. Don't set `fallbackHosts`; explicit hosts already disable fallbacks.
     
    -Steps 5 (compile) and 6 (run) still apply. Note that proxy tests hit the live sandbox and download the proxy binary on first run, so they are slower and require network access.
    +Step 5 (compile) still applies; Step 6 (run) applies only in evaluate mode (Step E). Note that proxy tests hit the live sandbox and download the proxy binary on first run, so they are slower and require network access.
    diff --git a/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py b/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py
    new file mode 100644
    index 000000000..fcc116dd2
    --- /dev/null
    +++ b/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py
    @@ -0,0 +1,165 @@
    +#!/usr/bin/env python3
    +"""Resolve a UTS spec module directory to its ably-java test targets.
    +
    +Deterministic helper for the uts-to-kotlin skill. Given a UTS spec *module*
    +directory (a directory directly under .../specification/uts/), it:
    +
    +  - validates the path and the module's tier structure,
    +  - reads uts-package-mapping.json (next to this script's skill dir),
    +  - resolves, per tier, the target output directory and Kotlin package, and
    +  - lists the candidate spec files with their derived Kotlin class names.
    +
    +Doing this in code (rather than asking the model to eyeball regexes, join
    +paths, and hand-convert snake_case -> PascalCase every run) keeps the skill's
    +selection phase byte-for-byte deterministic.
    +
    +Usage:
    +  resolve_uts.py                  # validate + resolve + list specs
    +  resolve_uts.py  --create NAME   # add a mapping entry for this
    +                                              # module (target base name NAME),
    +                                              # then resolve
    +
    +Always prints a single JSON object to stdout. On failure: {"ok": false,
    +"error": , "message": ...} and a non-zero exit.
    +"""
    +import argparse
    +import json
    +import re
    +import sys
    +from pathlib import Path
    +
    +SKILL_DIR = Path(__file__).resolve().parent.parent
    +MAPPING = SKILL_DIR / "uts-package-mapping.json"
    +TIERS = ("unit", "integration", "proxy")
    +
    +
    +def fail(code, message):
    +    print(json.dumps({"ok": False, "error": code, "message": message}, indent=2))
    +    sys.exit(1)
    +
    +
    +def class_name(md_path: Path) -> str:
    +    """objects_batch_test.md -> ObjectsBatchTest; instance.md -> InstanceTest."""
    +    stem = md_path.stem
    +    if stem.endswith("_test"):
    +        stem = stem[: -len("_test")]
    +    return "".join(part.capitalize() for part in stem.split("_") if part) + "Test"
    +
    +
    +def is_nonspec_doc(name: str) -> bool:
    +    """README/PLAN/*_SUMMARY markdown are docs, not test specs."""
    +    if re.fullmatch(r"(README|PLAN)\.md", name, re.IGNORECASE):
    +        return True
    +    return name.upper().endswith("_SUMMARY.MD")
    +
    +
    +def list_specs(base: Path, exclude_proxy: bool = False):
    +    """List spec .md files under `base`, deterministically.
    +
    +    All exclusions are checked against the path **relative to base**, so they
    +    can't be tripped by an ancestor directory in the checkout path (e.g. a
    +    clone living under some `.../helpers/...` path).
    +    """
    +    if not base.is_dir():
    +        return []
    +    specs = []
    +    for p in sorted(base.rglob("*.md")):
    +        rel_parts = p.relative_to(base).parts
    +        if "helpers" in rel_parts:
    +            continue
    +        if exclude_proxy and "proxy" in rel_parts:
    +            continue
    +        if is_nonspec_doc(p.name):
    +            continue
    +        specs.append(p)
    +    return specs
    +
    +
    +def package_for(target_dir: str) -> str:
    +    marker = "src/test/kotlin/"
    +    idx = target_dir.find(marker)
    +    return target_dir[idx + len(marker):].replace("/", ".") if idx != -1 else ""
    +
    +
    +def main():
    +    ap = argparse.ArgumentParser()
    +    ap.add_argument("module_dir")
    +    ap.add_argument(
    +        "--create",
    +        metavar="NAME",
    +        help="add a mapping for this source module using NAME as the ably-java "
    +        "module base name, then resolve",
    +    )
    +    args = ap.parse_args()
    +
    +    raw = args.module_dir.rstrip("/")
    +    if not re.search(r"/uts/[^/]+$", raw):
    +        fail("NOT_A_UTS_MODULE_PATH",
    +             f"{raw!r} is not a module directory directly under uts/ "
    +             f"(expected .../uts/).")
    +    module_dir = Path(raw)
    +    if not module_dir.is_dir():
    +        fail("DIR_NOT_FOUND", f"{raw!r} does not exist or is not a directory.")
    +    if not (module_dir / "unit").is_dir() and not (module_dir / "integration").is_dir():
    +        fail("NO_TIER_DIRS",
    +             f"{raw!r} has no unit/ or integration/ sub-directory; "
    +             f"not a valid UTS module.")
    +
    +    source_module = module_dir.name
    +
    +    if not MAPPING.is_file():
    +        fail("MAPPING_NOT_FOUND", f"mapping file not found at {MAPPING}")
    +    data = json.loads(MAPPING.read_text())
    +    packages = data.setdefault("packages", {})
    +    test_root = data.get("testRoot", "")
    +
    +    if args.create:
    +        target = args.create
    +        if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", target):
    +            fail("BAD_TARGET_NAME",
    +                 f"--create target {target!r} must be a simple module base name "
    +                 f"(letters/digits/underscore, e.g. 'liveobjects') so it forms a "
    +                 f"valid path and Kotlin package.")
    +        packages[source_module] = {
    +            "unit": f"unit/{target}",
    +            "integration": f"integration/standard/{target}",
    +            "proxy": f"integration/proxy/{target}",
    +        }
    +        MAPPING.write_text(json.dumps(data, indent=2) + "\n")
    +
    +    mapped = source_module in packages
    +    entry = packages.get(source_module, {})
    +
    +    src = {
    +        "unit": module_dir / "unit",
    +        "integration": module_dir / "integration",
    +        "proxy": module_dir / "integration" / "proxy",
    +    }
    +    specs = {
    +        "unit": list_specs(src["unit"]),
    +        "integration": list_specs(src["integration"], exclude_proxy=True),
    +        "proxy": list_specs(src["proxy"]),
    +    }
    +
    +    tiers_out = {}
    +    for tier in TIERS:
    +        target_dir = f"{test_root}/{entry[tier]}" if (mapped and tier in entry) else None
    +        tiers_out[tier] = {
    +            "present": src[tier].is_dir(),
    +            "sourceDir": str(src[tier]),
    +            "targetDir": target_dir,
    +            "package": package_for(target_dir) if target_dir else None,
    +            "specs": [{"file": str(p), "className": class_name(p)} for p in specs[tier]],
    +        }
    +
    +    print(json.dumps({
    +        "ok": True,
    +        "sourceModule": source_module,
    +        "mapped": mapped,
    +        "testRoot": test_root,
    +        "tiers": tiers_out,
    +    }, indent=2))
    +
    +
    +if __name__ == "__main__":
    +    main()
    
    From 0068fe918500830d6cc98c31da236c44f695fc8e Mon Sep 17 00:00:00 2001
    From: sacOO7 
    Date: Thu, 25 Jun 2026 23:57:50 +0530
    Subject: [PATCH 35/40] =?UTF-8?q?feat(uts-to-kotlin):=20add=20ably-js=20?=
     =?UTF-8?q?=E2=86=92=20ably-java=20liveobjects=20mapping=20reference?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    The objects UTS specs are written in ably-js-style pseudocode, but ably-java
    is a typed SDK (RTTS1-10 partition). Add references/objects-mapping.md mapping
    each ably-js symbol to its ably-java equivalent: entry point, async
    (CompletableFuture/await), the typed PathObject/Instance hierarchies and as*
    casts, the LiveMapValue write union, creation value types, subscriptions,
    sync-state events, ValueType, message/operation getters, error codes, path
    dot-escaping, and the internal-graph caveat for unit specs.
    
    Wire it in deterministically: each module declares its translation reference
    via a `notes` field in uts-package-mapping.json; resolve_uts.py resolves it to
    `translationNotes` (absolute path, or null), and SKILL.md makes it required
    reading before Phase 2 when present.
    ---
     .claude/skills/uts-to-kotlin/SKILL.md         |  23 +-
     .../references/objects-mapping.md             | 596 ++++++++++++++++++
     .../uts-to-kotlin/scripts/resolve_uts.py      |  16 +-
     .../uts-to-kotlin/uts-package-mapping.json    |   5 +-
     4 files changed, 631 insertions(+), 9 deletions(-)
     create mode 100644 .claude/skills/uts-to-kotlin/references/objects-mapping.md
    
    diff --git a/.claude/skills/uts-to-kotlin/SKILL.md b/.claude/skills/uts-to-kotlin/SKILL.md
    index 80bfb513e..ca1cda668 100644
    --- a/.claude/skills/uts-to-kotlin/SKILL.md
    +++ b/.claude/skills/uts-to-kotlin/SKILL.md
    @@ -44,10 +44,15 @@ python3 .claude/skills/uts-to-kotlin/scripts/resolve_uts.py ""
     
     It prints one JSON object. **If `ok` is `false`, relay `message` to the user and stop** — error codes:
     `NOT_A_UTS_MODULE_PATH` (not a `.../uts/` directory), `DIR_NOT_FOUND`, `NO_TIER_DIRS` (no `unit/`
    -or `integration/`). On success it gives `sourceModule`, `mapped`, `testRoot`, and a `tiers` object with one
    -entry per tier (`unit` / `integration` / `proxy`), each carrying `present`, `sourceDir`, `targetDir`,
    -`package`, and `specs` (a list of `{file, className}`). Everything downstream reads from this output — treat
    -it as the single source of truth and don't recompute paths or names by hand.
    +or `integration/`). On success it gives `sourceModule`, `mapped`, `testRoot`, `translationNotes`, and a
    +`tiers` object with one entry per tier (`unit` / `integration` / `proxy`), each carrying `present`,
    +`sourceDir`, `targetDir`, `package`, and `specs` (a list of `{file, className}`). Everything downstream
    +reads from this output — treat it as the single source of truth and don't recompute paths or names by hand.
    +
    +`translationNotes` is the path to a per-module ably-js → ably-java type/interface map when the module
    +declares one (its `notes` field in `uts-package-mapping.json`, e.g. `objects` →
    +`references/objects-mapping.md`), else `null`. When it's non-null, it is **required reading before
    +Phase 2** — see Step 1.
     
     ## Step B — Confirm or create the target mapping
     
    @@ -113,9 +118,15 @@ When translating several specs, do Steps 1–4 (generate the file) for every spe
     compiling once is faster than per-file and surfaces cross-file issues together. For a single spec, just go
     through the steps in order.
     
    -## Step 1 — Read the spec
    +## Step 1 — Read the spec (and any module translation notes)
    +
    +**If Step A reported a non-null `translationNotes`, read that file first (once per run).** UTS specs are
    +written in a language-agnostic pseudocode that mirrors the *ably-js* API; for modules whose ably-java types
    +diverge (e.g. `objects` → `liveobjects`, where ably-java is a typed SDK with a partitioned `PathObject` /
    +`Instance` hierarchy and a `LiveMapValue` write union), the notes map each spec symbol to its ably-java
    +equivalent. Skipping them yields tests that read like ably-js and won't compile.
     
    -Read the current spec file (the one being translated from the Step D selection). Identify:
    +Then read the current spec file (the one being translated from the Step D selection). Identify:
     - All test cases — each has a structured ID like `realtime/unit/RSA4c2/callback-error-connecting-disconnected-0` and a description
     - The protocol used (WebSocket for Realtime, HTTP for REST)
     - Any timer usage (`enable_fake_timers`, `ADVANCE_TIME`)
    diff --git a/.claude/skills/uts-to-kotlin/references/objects-mapping.md b/.claude/skills/uts-to-kotlin/references/objects-mapping.md
    new file mode 100644
    index 000000000..c30b9f34d
    --- /dev/null
    +++ b/.claude/skills/uts-to-kotlin/references/objects-mapping.md
    @@ -0,0 +1,596 @@
    +# `objects` UTS → ably-java `liveobjects`: ably-js ⇄ ably-java type/interface map
    +
    +Read this **before translating any spec from the `objects` module** (target ably-java module
    +`liveobjects`). The `objects` UTS specs are written in a language-agnostic pseudocode that mirrors the
    +**ably-js** LiveObjects API — a dynamically-typed surface with a single polymorphic `PathObject` /
    +`Instance`, `Promise`-returning mutators, and raw JS values. ably-java is a **statically-typed SDK** and
    +implements the *Typed-SDK variant* of the spec (`RTTS1`–`RTTS10` in `objects-features.md`): it partitions
    +that one polymorphic class into a typed hierarchy and wraps write values in a union type. So almost every
    +spec line needs a mechanical rewrite, not a literal transcription. This doc is that rewrite table.
    +
    +The canonical bridge is the spec's own Interface Definition (`## Interface Definition {#idl}`) and its
    +`=== Typed-SDK variant (RTTS1-RTTS10) ===` block — ably-java follows the typed variant verbatim. When in
    +doubt, that IDL is the source of truth; this doc is the applied version of it for ably-java.
    +
    +## Table of contents
    +
    +1. [The three layers (don't conflate them)](#1-the-three-layers)
    +2. [Entry point & channel access](#2-entry-point)
    +3. [Async: Promise/await → CompletableFuture](#3-async)
    +4. [Dynamic `PathObject` → typed `PathObject` hierarchy](#4-pathobject)
    +5. [Dynamic `Instance` → typed `Instance` hierarchy](#5-instance)
    +6. [Creation value types & the `LiveMapValue` union](#6-value-types)
    +7. [Mutations (set / remove / increment / decrement)](#7-mutations)
    +8. [Subscriptions, listeners & events](#8-subscriptions)
    +9. [Sync-state events (`object.on('synced')`)](#9-sync-state)
    +10. [`ValueType` & type discrimination](#10-valuetype)
    +11. [Message / operation types (`PublicAPI::ObjectMessage` →)](#11-messages)
    +12. [Errors & error codes](#12-errors)
    +13. [Internal-graph types (unit specs) — important caveat](#13-internal-graph)
    +14. [Worked example](#14-worked-example)
    +15. [Quick symbol index](#15-symbol-index)
    +
    +---
    +
    +## 1. The three layers 
    +
    +The single biggest source of confusion: the spec uses the names `LiveMap` / `LiveCounter` for **two
    +different things**, and a third *internal* layer underneath. Keep them straight:
    +
    +| Layer | Spec name | ably-js | ably-java | Package |
    +|---|---|---|---|---|
    +| **Creation value type** — immutable blueprint you pass *into* `set` | `LiveMap` / `LiveCounter` (the `RTLMV*` / `RTLCV*` classes) | `LiveMap.create()` / `LiveCounter.create()` | `LiveMap` / `LiveCounter` | `io.ably.lib.liveobjects.value` |
    +| **Public read/write view** — what you navigate & subscribe on | `PathObject`, `Instance` | `PathObject`, `Instance` | typed hierarchy (§4, §5) | base in `io.ably.lib.liveobjects.path` / `.instance`; **typed subtypes in `.path.types` / `.instance.types`** |
    +| **Internal graph object** — the live CRDT node | `InternalLiveMap` / `InternalLiveCounter` (`RTLM*` / `RTLC*`), `ObjectsPool` | internal | `DefaultLiveMap` / `DefaultLiveCounter` etc. (impl, `:liveobjects` module) | not public — see §13 |
    +
    +So when a spec says `counter = LiveCounter.create(5)` and passes it to `set`, that's the **value type**
    +(`io.ably.lib.liveobjects.value.LiveCounter`). When a spec says "the resolved value is an
    +`InternalLiveCounter` with `.data == 5`", that's the **internal graph node** (§13). When a spec navigates
    +`root.get("counter").value()`, that's the **public view** (`PathObject`).
    +
    +---
    +
    +## 2. Entry point & channel access 
    +
    +| Spec / ably-js | ably-java (Kotlin) |
    +|---|---|
    +| `channel.object` (objects entry point) | `` channel.`object` `` — a **public field** of type `RealtimeObject`, *not* a method. (Declared `public RealtimeObject object;` on `ChannelBase`.) |
    +| `root = AWAIT channel.object.get()` | `` val root: LiveMapPathObject = channel.`object`.get().await() `` — returns `CompletableFuture` (always a `LiveMapPathObject`, per `RTTS6d`/`RTO23f`). |
    +| `channel.object.get()` (ably-js generic) | **No generic.** ably-java is untyped at the root; you always get a `LiveMapPathObject` and narrow downstream with `as*` casts (§4). Drop the type parameter entirely. |
    +| Channel needs object modes | `TestRealtimeClient { … }` then `channels.get(name, ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) })`. (`ChannelMode` constants are lower-case: `object_subscribe`, `object_publish`; `ChannelOptions.modes` is a `ChannelMode[]`.) |
    +
    +> ⚠️ **`object` is a hard keyword in Kotlin.** The entry-point field is named `object` in Java, so from
    +> Kotlin you **must** escape it with backticks: `` channel.`object` ``. Bare `channel.object` is a compile
    +> error. This applies everywhere in this doc and in generated tests.
    +
    +`RealtimeObject` extends `ObjectStateChange` (sync-state subscription, §9). When the plugin isn't installed
    +the field is `RealtimeObject.Unavailable.INSTANCE`; real tests install it.
    +
    +---
    +
    +## 3. Async: Promise / await → CompletableFuture 
    +
    +Every spec `AWAIT`/Promise-returning call maps to a `CompletableFuture<…>` in ably-java:
    +
    +| Spec / ably-js | ably-java return type |
    +|---|---|
    +| `AWAIT channel.object.get()` | `CompletableFuture` |
    +| `AWAIT pathObj.set(k, v)` / `.remove(k)` | `CompletableFuture` |
    +| `AWAIT counterObj.increment(n)` / `.decrement(n)` | `CompletableFuture` |
    +| `AWAIT instance.set(...)` etc. | `CompletableFuture` |
    +
    +Subscriptions are **not** futures — `subscribe(...)` returns a `Subscription` synchronously (`@NonBlocking`).
    +
    +**Awaiting a `CompletableFuture` inside a `runTest { }` body:** use `future.await()` with
    +`import kotlinx.coroutines.future.await` — the `future` integration ships inside `kotlinx-coroutines-core`
    +(verified on the version the uts module resolves), so no extra dependency is needed. Use the blocking
    +`future.get(timeout, unit)` only if a specific test needs to assert synchronously; prefer `await()` so the
    +test stays within structured concurrency.
    +
    +---
    +
    +## 4. Dynamic `PathObject` → typed `PathObject` hierarchy 
    +
    +In the spec/ably-js a `PathObject` is polymorphic: `get`, `at`, `value`, `set`, `increment`, `entries`…
    +all hang off the one object. In ably-java the base `PathObject` exposes **only** the type-agnostic methods;
    +everything type-specific is moved onto a sub-interface you reach via an `as*` cast.
    +
    +**Base `PathObject`** (`io.ably.lib.liveobjects.path.PathObject`) — always available. The typed sub-types
    +returned by the `as*` casts (`LiveMapPathObject`, `LiveCounterPathObject`, `NumberPathObject`,
    +`StringPathObject`, `BooleanPathObject`, `BinaryPathObject`, `JsonObjectPathObject`, `JsonArrayPathObject`)
    +live in **`io.ably.lib.liveobjects.path.types`** (not `.path`) — import them from there.
    +
    +| Spec / ably-js | ably-java |
    +|---|---|
    +| `pathObj.path()` | `pathObj.path(): String` |
    +| `pathObj.instance()` | `pathObj.instance(): Instance?` (null if path resolves to a primitive, or doesn't resolve) |
    +| `pathObj.compactJson()` | `pathObj.compactJson(): JsonElement?` |
    +| `pathObj.compact()` | **Not implemented in ably-java** (`RTTS3f`: typed SDKs need not implement `compact`). Use `compactJson()` for snapshot assertions; if a spec genuinely needs the non-JSON `compact()` shape, that's a deviation — flag it. |
    +| `pathObj.subscribe(listener[, opts])` | `pathObj.subscribe(PathObjectListener[, PathObjectSubscriptionOptions]): Subscription` (§8) |
    +| *(typed-SDK addition)* exists check | `pathObj.exists(): Boolean` (`RTTS4a`) |
    +| `pathObj.getType()` | `pathObj.getType(): ValueType?` — null when nothing resolves (§10) |
    +| — cast helpers — | `asLiveMap()`, `asLiveCounter()`, `asNumber()`, `asString()`, `asBoolean()`, `asBinary()`, `asJsonObject()`, `asJsonArray()` |
    +
    +**`PathObject` casts never throw** (`RTTS5d`) — they only re-wrap. A wrong cast surfaces later: read ops on
    +the wrong-typed view return `null`/empty; write ops throw (§12). So `root.get("k").asNumber().value()`
    +returns `null` if `k` isn't a number, rather than throwing.
    +
    +**Map-only methods** — require `asLiveMap()` → `LiveMapPathObject`:
    +
    +| Spec / ably-js (on a `PathObject`) | ably-java |
    +|---|---|
    +| `pathObj.get(key)` | `pathObj.asLiveMap().get(key): PathObject` |
    +| `pathObj.at("a.b.c")` | `pathObj.asLiveMap().at("a.b.c"): PathObject` |
    +| `pathObj.entries()` | `pathObj.asLiveMap().entries(): Iterable>` |
    +| `pathObj.keys()` / `.values()` | `pathObj.asLiveMap().keys(): Iterable` / `.values(): Iterable` |
    +| `pathObj.size()` | `pathObj.asLiveMap().size(): Long?` |
    +| `pathObj.set(key, value)` | `pathObj.asLiveMap().set(key, LiveMapValue.of(value))` (§6, §7) |
    +| `pathObj.remove(key)` | `pathObj.asLiveMap().remove(key)` |
    +
    +> The **root** is already a `LiveMapPathObject` (from `channel.object.get()`), so `root.get(...)` /
    +> `root.set(...)` need no cast — only deeper, freshly-navigated `PathObject`s do.
    +
    +**Iterating & membership.** `entries()` returns `Iterable>`; `keys()` /
    +`values()` return `Iterable<…>`. The spec's tuple-destructuring loops and `IN` membership map to Kotlin
    +directly:
    +
    +```
    +# spec
    +FOR [key, pathObj] IN root.entries(): …
    +ASSERT "name" IN root.keys()
    +keys = list(root.keys())
    +```
    +```kotlin
    +for ((key, pathObj) in root.entries()) { … }     // Map.Entry destructures into (key, value)
    +assertTrue("name" in root.keys())                 // Kotlin `in` -> Iterable.contains
    +val keys = root.keys().toList()                    // when the spec materialises a list / checks length
    +```
    +
    +These live on `LiveMapPathObject`, so a *navigated* node needs `asLiveMap()` first
    +(`root.get("score").asLiveMap().entries()`); `root` itself doesn't.
    +
    +**Counter-only methods** — require `asLiveCounter()` → `LiveCounterPathObject`:
    +
    +| Spec / ably-js | ably-java |
    +|---|---|
    +| `pathObj.value()` *(when it's a counter)* | `pathObj.asLiveCounter().value(): Double?` (counter value, else null) |
    +| `pathObj.increment([n])` | `pathObj.asLiveCounter().increment()` / `.increment(n: Number)` |
    +| `pathObj.decrement([n])` | `pathObj.asLiveCounter().decrement()` / `.decrement(n: Number)` |
    +
    +**Primitive value reads** — the spec's single `pathObj.value()` splits by primitive type. Cast to the
    +matching primitive sub-type (`NumberPathObject`, `StringPathObject`, `BooleanPathObject`, `BinaryPathObject`,
    +`JsonObjectPathObject`, `JsonArrayPathObject`) and call `value()` (each returns its type or `null`):
    +
    +| Spec resolves to | ably-java |
    +|---|---|
    +| number | `pathObj.asNumber().value(): Number?` |
    +| string | `pathObj.asString().value(): String?` |
    +| boolean | `pathObj.asBoolean().value(): Boolean?` |
    +| binary | `pathObj.asBinary().value(): ByteArray?` |
    +| JSON object | `pathObj.asJsonObject().value(): JsonObject?` |
    +| JSON array | `pathObj.asJsonArray().value(): JsonArray?` |
    +
    +> The dynamic `PathObject#value` (`RTPO7`) returns "the resolved counter value *or* any primitive". The
    +> typed `value()` accessors are **stricter** (`RTTS6g`): each returns `null` unless the resolved value is
    +> exactly that type. Translate "ASSERT pathObj.value() == 5" against a counter as
    +> `assertEquals(5.0, root.get("c").asLiveCounter().value())`, not `asNumber()`.
    +>
    +> **Number comparison gotcha.** Specs assert against integer literals (`value() == 110`, `size() == 7`),
    +> but ably-java returns wider numeric types: counter `value()` is `Double` (assert `110.0`); primitive
    +> `asNumber().value()` is a boxed `Number` whose runtime type follows JSON decoding; and `size()` is `Long`
    +> (assert `7L`). `assertEquals` treats `110.0`/`110`/`110L` as unequal across `Double`/`Int`/`Long`, so
    +> normalise: `assertEquals(110.0, obj.asNumber().value()?.toDouble())`, `assertEquals(7L, root.size())`. A
    +> spec `size() == null` (called on a non-map) is `assertNull(node.asLiveMap().size())` — the cast doesn't
    +> throw and `size()` returns null off-map.
    +
    +**Path strings & dot-escaping (`RTPO4`/`RTPO4b`/`RTPO6`).** `path()` returns a dot-delimited `String`; the
    +root's is `""`. A literal dot *inside* a segment is escaped as `\.`, and `at()` parses `\.` back to a
    +literal dot — so `path()` round-trips. Mind Kotlin's own backslash escaping (`"a\\.b.c"` is the string
    +`a\.b.c`):
    +
    +```
    +# spec                                    # ably-java (Kotlin)
    +ASSERT root.path() == ""                   assertEquals("", root.path())
    +ASSERT root.get("a").get("b").path()       assertEquals("a.b", root.get("a").asLiveMap().get("b").path())
    +       == "a.b"
    +po = root.at("a\.b.c")                      val po = root.at("a\\.b.c")          // segments ["a.b", "c"]
    +ASSERT po.path() == "a\.b.c"               assertEquals("a\\.b.c", po.path())
    +```
    +
    +---
    +
    +## 5. Dynamic `Instance` → typed `Instance` hierarchy 
    +
    +Same partition as `PathObject`, with two differences: the base is **abstract / never instantiated**
    +directly (`RTTS7e`), and the casts **throw** on mismatch instead of degrading (`RTTS9d`) — because an
    +`Instance` wraps an already-resolved value of known type.
    +
    +**Base `Instance`** (`io.ably.lib.liveobjects.instance.Instance`):
    +
    +| Spec / ably-js | ably-java |
    +|---|---|
    +| `instance.getType()` | `instance.getType(): ValueType` (non-null — never `UNKNOWN` in normal operation, `RTTS8a`) |
    +| `instance.compactJson()` | `instance.compactJson(): JsonElement` (**non-null**, `RTINS11c`) |
    +| `instance.compact()` | **Not implemented in ably-java** (`RTTS7d`, same as `PathObject`). Use `compactJson()`; flag a deviation if a spec needs `compact()`. |
    +| — casts — | `asLiveMap()`, `asLiveCounter()`, `asNumber()`, `asString()`, `asBoolean()`, `asBinary()`, `asJsonObject()`, `asJsonArray()` — **throw `IllegalStateException`** (or `AblyException` 400/92007) on type mismatch |
    +
    +> `subscribe` is **not** on the base `Instance` (`RTTS7b`) — only on `LiveMapInstance` / `LiveCounterInstance`.
    +> `id`, `value`, `get`, `set`, … are all partitioned onto sub-classes too (`RTTS7c`).
    +
    +**`LiveMapInstance`** (`…instance.types.LiveMapInstance`):
    +
    +| Spec / ably-js | ably-java |
    +|---|---|
    +| `instance.id` | `getId(): String` (non-null, `RTTS10a`) |
    +| `instance.get(key)` | `get(key): Instance?` |
    +| `instance.entries()` / `.keys()` / `.values()` | `entries(): Iterable>` / `keys(): Iterable` / `values(): Iterable` |
    +| `instance.size()` | `size(): Long` (non-null here, `RTTS10a`) |
    +| `instance.set(key, value)` / `.remove(key)` | `set(key, LiveMapValue.of(value))` / `remove(key)` → `CompletableFuture` |
    +| `instance.subscribe(listener)` | `subscribe(InstanceListener): Subscription` |
    +| `instance.compactJson()` | `compactJson(): JsonObject` (narrowed) |
    +
    +**`LiveCounterInstance`** (`…instance.types.LiveCounterInstance`):
    +
    +| Spec / ably-js | ably-java |
    +|---|---|
    +| `instance.id` | `getId(): String` |
    +| `instance.value()` | `value(): Double` (non-null, `RTTS10b`) |
    +| `instance.increment([n])` / `.decrement([n])` | `increment()` / `increment(n)` / `decrement()` / `decrement(n)` → `CompletableFuture` |
    +| `instance.subscribe(listener)` | `subscribe(InstanceListener): Subscription` |
    +| `instance.compactJson()` | `compactJson(): JsonPrimitive` (narrowed) |
    +
    +**Primitive instances** (`NumberInstance`, `StringInstance`, `BooleanInstance`, `BinaryInstance`,
    +`JsonObjectInstance`, `JsonArrayInstance`) are **read-only**: each exposes a non-null `value()` of its type
    +and a narrowed `compactJson()`; no `id`, `get`, `set`, `subscribe`, etc.
    +
    +---
    +
    +## 6. Creation value types & the `LiveMapValue` union 
    +
    +ably-java can't accept "any JS value" into `set`, so it uses a tagged union `LiveMapValue` and dedicated
    +immutable creation value types.
    +
    +| Spec / ably-js | ably-java |
    +|---|---|
    +| `LiveCounter.create()` | `LiveCounter.create(): LiveCounter` (`io.ably.lib.liveobjects.value`) |
    +| `LiveCounter.create(5)` | `LiveCounter.create(5)` (arg is `Number`) |
    +| `LiveMap.create()` | `LiveMap.create(): LiveMap` |
    +| `LiveMap.create({ a: 1, b: "x" })` | `LiveMap.create(mapOf("a" to LiveMapValue.of(1), "b" to LiveMapValue.of("x")))` — entries are `Map` |
    +| a raw value passed to `set` | wrap it: `LiveMapValue.of(value)` |
    +
    +`LiveMapValue.of(...)` overloads: `Boolean`, `ByteArray` (binary), `Number`, `String`, `JsonArray`,
    +`JsonObject`, `LiveCounter` (value type), `LiveMap` (value type). Inspect with `isNumber()` / `getAsNumber()`
    +etc. when a spec asserts on a constructed value's contents.
    +
    +> **Type-safety turns several "invalid input" spec cases into compile errors, not runtime assertions.**
    +> Where a spec feeds a deliberately wrong type and expects an `ErrorInfo`, ably-java's signatures reject it
    +> at compile time, so the test isn't expressible — note it as a deviation rather than forcing it:
    +> - Passing a **graph object / public view** (`PathObject`, `Instance`, a live object) as a map value
    +>   (`RTLMV4c1`, runtime `40013` in the dynamic API) — blocked by the `LiveMapValue` union.
    +> - **Wrong-typed `create` args**, e.g. `LiveCounter.create("not_a_number")` (spec expects `40003`) —
    +>   blocked by `create(Number)`; `LiveMap.create` likewise takes `Map` so non-`Dict`
    +>   / non-`String`-key / unsupported-value entry cases (`RTLMV4a`/`b`/`c`) can't be constructed either.
    +>
    +> Validation cases on *values the type system still allows* (e.g. a NaN / out-of-range `Number`) remain
    +> real runtime assertions — only the cases the signature outright forbids become deviations.
    +
    +---
    +
    +## 7. Mutations (set / remove / increment / decrement) 
    +
    +Putting §4 + §6 together — the canonical write translations:
    +
    +```
    +# spec
    +AWAIT root.set("count", LiveCounter.create(0))
    +AWAIT root.get("count").increment(5)
    +AWAIT root.set("name", "alice")
    +AWAIT root.remove("name")
    +```
    +```kotlin
    +// ably-java (root is a LiveMapPathObject)
    +root.set("count", LiveMapValue.of(LiveCounter.create(0))).await()
    +root.get("count").asLiveCounter().increment(5).await()
    +root.set("name", LiveMapValue.of("alice")).await()
    +root.remove("name").await()
    +```
    +
    +- `set` / `remove` live on `LiveMapPathObject` (or `LiveMapInstance`); navigate+`asLiveMap()` first unless
    +  you're on the root or an already-typed map view.
    +- `increment` / `decrement` live on `LiveCounterPathObject` (or `LiveCounterInstance`); `asLiveCounter()`
    +  first.
    +- Default-amount forms exist: `increment()` ≡ `increment(1)`, `decrement()` ≡ `decrement(1)`.
    +
    +### Wrong-type write failures still go *through* the cast
    +
    +A common spec shape is a write on the wrong kind of object, expecting a runtime error — e.g.
    +`AWAIT root.increment(5) FAILS WITH error` (increment on a map) or `counter.set("k", v) FAILS WITH error`.
    +In the dynamic API every method exists on every `PathObject`, so the call is expressible and throws at
    +runtime. In ably-java the typed view **doesn't have that method at all** (`LiveMapPathObject` has no
    +`increment`; `LiveCounterPathObject` has no `set`), so calling it directly is a *compile* error — not the
    +runtime failure the spec is testing.
    +
    +To translate these, cast to the view whose write method you need (the `PathObject` cast never throws,
    +`RTTS5d`), then assert the **operation** throws — that's where the `92007` surfaces:
    +
    +```
    +# spec: increment on a map fails
    +AWAIT root.increment(5) FAILS WITH error   # code 92007
    +```
    +```kotlin
    +val ex = assertFailsWith { root.asLiveCounter().increment(5).await() }
    +assertEquals(92007, ex.errorInfo.code)
    +```
    +
    +So "can't call increment on a map" is **not** "not expressible" — it's `asLiveCounter().increment(...)`
    +plus an assertion on the throw. (Contrast §6: invalid *value* / *argument-type* cases genuinely aren't
    +expressible, because the union/`create(Number)` signatures reject them at compile time.)
    +
    +---
    +
    +## 8. Subscriptions, listeners & events 
    +
    +ably-js passes a closure and gets back a `Subscription` with `.unsubscribe()`. ably-java uses named
    +single-method listener interfaces; the event is an object with getters.
    +
    +| Spec / ably-js | ably-java |
    +|---|---|
    +| `sub = pathObj.subscribe((event) => { … })` | `val sub = pathObj.subscribe(PathObjectListener { event -> … })` |
    +| `pathObj.subscribe(listener, { depth: 2 })` | `pathObj.subscribe(listener, PathObjectSubscriptionOptions(2))` (no-arg ctor = unlimited depth) |
    +| `sub = instance.subscribe((event) => { … })` | `val sub = mapOrCounterInstance.subscribe(InstanceListener { event -> … })` |
    +| `sub.unsubscribe()` | `sub.unsubscribe()` |
    +| `event.object` | `event.getObject()` — a `PathObject` (path sub) / `Instance` (instance sub) |
    +| `event.message` | `event.getMessage(): ObjectMessage?` (§11) |
    +
    +Listener SAMs: `PathObjectListener.onUpdated(PathObjectSubscriptionEvent)`,
    +`InstanceListener.onUpdated(InstanceSubscriptionEvent)`. In Kotlin you can pass a lambda for either (SAM
    +conversion). `PathObjectSubscriptionOptions(depth)` throws `AblyException` 400/`40003` for non-positive
    +depth (`RTPO19c1`).
    +
    +> **`LiveObjectUpdate` is not the public event.** `live_object_subscribe.md` cites the internal `RTLO4b`
    +> `LiveObjectUpdate` (fields `update` / `noop` / `objectMessage` / `tombstone`), but it subscribes through
    +> the *public* `instance.subscribe(...)`, whose ably-java event is `InstanceSubscriptionEvent` — only
    +> `getObject()` + `getMessage()`, **no diff/`noop`/`tombstone` accessors**. So "listener fired N times" and
    +> "returns a `Subscription`" translate directly, but any assertion on the `LiveObjectUpdate` *diff* fields
    +> is internal (§13) — adapt or skip with a deviation.
    +
    +---
    +
    +## 9. Sync-state events (`object.on('synced')`) 
    +
    +`RealtimeObject` extends `ObjectStateChange`. The ably-js string-event API becomes an enum + listener:
    +
    +| Spec / ably-js | ably-java |
    +|---|---|
    +| `channel.object.on('synced', cb)` | `` channel.`object`.on(ObjectStateEvent.SYNCED, listener): Subscription `` |
    +| `channel.object.on('syncing', cb)` | `` channel.`object`.on(ObjectStateEvent.SYNCING, listener) `` |
    +| `channel.object.off(cb)` | `` channel.`object`.off(listener) `` |
    +| remove all | `` channel.`object`.offAll() `` |
    +
    +Listener: `ObjectStateChange.Listener.onStateChanged(ObjectStateEvent)`. Enum `ObjectStateEvent { SYNCING,
    +SYNCED }` (`io.ably.lib.liveobjects.state`).
    +
    +---
    +
    +## 10. `ValueType` & type discrimination 
    +
    +ably-js uses string-literal type tags; ably-java has an enum `io.ably.lib.liveobjects.ValueType`:
    +
    +| Spec value category | `ValueType` |
    +|---|---|
    +| string / number / boolean / binary | `STRING` / `NUMBER` / `BOOLEAN` / `BINARY` |
    +| JSON object / JSON array | `JSON_OBJECT` / `JSON_ARRAY` |
    +| live map / live counter | `LIVE_MAP` / `LIVE_COUNTER` |
    +| present but unrecognised | `UNKNOWN` |
    +
    +`pathObj.getType()` returns `null` when nothing resolves (distinct from `UNKNOWN`); `instance.getType()` is
    +non-null. Use `getType()` for "what is it" assertions and the matching `as*` cast to read it.
    +
    +---
    +
    +## 11. Message / operation types 
    +
    +The spec's `PublicAPI::ObjectMessage` / `PublicAPI::ObjectOperation` (the `PAOM*` / `PAOOP*` types,
    +delivered to subscription listeners) map to ably-java interfaces with getters (package
    +`io.ably.lib.liveobjects.message`). The `PublicAPI::` prefix is dropped — ably-java exposes them as
    +`ObjectMessage` / `ObjectOperation`.
    +
    +> **Getter-only, no public constructor.** You obtain an `ObjectMessage` only from a subscription event
    +> (`event.getMessage()`, §8) — there is no public factory. The spec's *explicit* construction-from-wire
    +> (`PublicObjectMessage.fromObjectMessage(source, channel)` / `PublicObjectOperation.fromObjectOperation(op)`,
    +> `PAOM3`/`PAOOP3`, in `public_object_message.md`) is internal in ably-java, so assert the getters on a
    +> message received via `subscribe` rather than constructing one; treat a standalone construction-only test
    +> as internal (§13).
    +
    +`ObjectMessage`: `getId()`, `getClientId()`, `getConnectionId()`, `getTimestamp(): Long?`, `getChannel():
    +String`, `getOperation(): ObjectOperation`, `getSerial()`, `getSerialTimestamp(): Long?`, `getSiteCode()`,
    +`getExtras(): JsonObject?`. (Timestamps are epoch-millis `Long`, not a `Time` object.)
    +
    +`ObjectOperation`: `getAction(): ObjectOperationAction`, `getObjectId(): String`, and one non-null payload
    +getter matching the action — `getMapCreate()`, `getMapSet()`, `getMapRemove()`, `getCounterCreate()`,
    +`getCounterInc()`, `getObjectDelete()`, `getMapClear()`.
    +
    +**The spec accesses these as dotted property chains and compares `action` to a *string literal*; ably-java
    +uses getters and an *enum constant*.** Translate the chain getter-by-getter and the string tag to the enum:
    +
    +```
    +# spec
    +ASSERT msg.operation.action == "MAP_SET"
    +ASSERT msg.operation.mapSet.key == "name"
    +ASSERT msg.operation.mapSet.value.string == "blue"
    +ASSERT msg.operation.counterInc.number == 42
    +ASSERT msg.operation.mapCreate == null
    +```
    +```kotlin
    +val op = msg.operation
    +assertEquals(ObjectOperationAction.MAP_SET, op.action)   // string "MAP_SET" -> enum constant
    +assertEquals("name", op.mapSet!!.key)
    +assertEquals("blue", op.mapSet!!.value.string)           // ObjectData.getString()
    +assertEquals(42.0, op.counterInc!!.number)               // getNumber(): Double -> use .0
    +assertNull(op.mapCreate)
    +```
    +
    +(Java getters read as Kotlin properties: `msg.operation` ≡ `getOperation()`, `op.mapSet` ≡ `getMapSet()`,
    +etc. The payload getter for the non-matching actions returns `null`, so `mapCreate == null` → `assertNull`.)
    +Every spec string action tag maps to its `ObjectOperationAction` constant: `"MAP_SET"`→`MAP_SET`,
    +`"COUNTER_INC"`→`COUNTER_INC`, `"OBJECT_DELETE"`→`OBJECT_DELETE`, etc. The same string-tag→enum rule applies
    +to map semantics (`"lww"`→`ObjectsMapSemantics.LWW`) and value types (§10).
    +
    +| Spec type | ably-java | Notable getters |
    +|---|---|---|
    +| `ObjectOperationAction` | enum `ObjectOperationAction` | `MAP_CREATE, MAP_SET, MAP_REMOVE, COUNTER_CREATE, COUNTER_INC, OBJECT_DELETE, MAP_CLEAR, UNKNOWN` |
    +| `MapSet` | `MapSet` | `getKey(): String`, `getValue(): ObjectData` |
    +| `MapRemove` | `MapRemove` | `getKey(): String` |
    +| `MapCreate` | `MapCreate` | `getSemantics(): ObjectsMapSemantics`, `getEntries(): Map` |
    +| `CounterCreate` | `CounterCreate` | `getCount(): Double` |
    +| `CounterInc` | `CounterInc` | `getNumber(): Double` |
    +| `ObjectDelete` / `MapClear` | marker interfaces | no methods |
    +| `ObjectData` (leaf value) | `ObjectData` | `getObjectId()`, `getString()`, `getNumber(): Double?`, `getBoolean()`, `getBytes(): ByteArray?`, `getJson(): JsonElement?` |
    +| `ObjectsMapEntry` | `ObjectsMapEntry` | `getTombstone(): Boolean?`, `getTimeserial()`, `getSerialTimestamp(): Long?`, `getData(): ObjectData?` |
    +| map semantics | enum `ObjectsMapSemantics` | `LWW, UNKNOWN` |
    +
    +> Note `PublicAPI::ObjectOperation` carries only `mapCreate`/`counterCreate` (the `*WithObjectId` outbound
    +> variants are resolved back to their `MapCreate`/`CounterCreate` forms, `PAOOP1`). Don't expect a
    +> `getMapCreateWithObjectId()` on the public type.
    +
    +---
    +
    +## 12. Errors & error codes 
    +
    +Spec assertions like `FAILS WITH error code 92007` map to ably-java exceptions:
    +
    +| Spec failure | ably-java |
    +|---|---|
    +| async op rejects with `ErrorInfo` code N | the `CompletableFuture` completes exceptionally with `AblyException`. With `.await()` the cause is rethrown directly, so `assertFailsWith { … .await() }` then `ex.errorInfo.code == N`. With blocking `.get()` you instead catch `ExecutionException` and read `.cause` |
    +| wrong write method for the type (e.g. `increment` on a map, `set` on a counter) | the typed view lacks the method, so cast first (`asLiveCounter()` / `asLiveMap()` — never throws, `RTTS5d`) then the **operation** throws `AblyException` 400/`92007`. See §7 "Wrong-type write failures" |
    +| `Instance` `as*` cast on wrong type | **`IllegalStateException`** (or `AblyException` 400/`92007`) — thrown synchronously (`RTTS9d`) |
    +| `PathObject` `as*` cast on wrong type | **never throws** (`RTTS5d`) — failure shows up on the subsequent read (null) or write (throws, above) |
    +| invalid value into `set` (graph object / view) | `AblyException` 400 / code `40013` (`RTLMV4c1`) — usually not expressible in ably-java's typed `set`; treat as a deviation |
    +| non-positive subscription `depth` | `AblyException` 400 / code `40003` (`PathObjectSubscriptionOptions(int)`) |
    +| write where path doesn't resolve | `AblyException` 400 / code `92005` |
    +| write where value isn't the required type | `AblyException` 400 / code `92007` |
    +| `get()` / op when channel lacks the object mode (`RTO23a`/`RTO2a2`) | `AblyException` 400 / code `40024` |
    +| `get()` / access when channel is DETACHED or FAILED (`RTO23b`/`RTO25`) | `AblyException` 400 / code `90001` |
    +| channel enters DETACHED/SUSPENDED/FAILED while awaiting SYNCED (`RTO20e`/`RTO23c`) | `AblyException` 400 / code `92008` |
    +| write while `echoMessages` is false (`RTO26c`) | `AblyException` 400 / code `40000` |
    +
    +Assert the code as a plain int — `assertEquals(90001, ex.errorInfo.code)` — matching the spec's
    +`error.code == 90001`; error codes are int literals, not enums (unlike the action / semantics / value-type
    +tags). The `90000` a spec injects via a mocked `ERROR`/`DETACHED` `ProtocolMessage` is the channel-level
    +error, not an objects code — it's what drives the channel into the state that makes the objects call fail.
    +
    +---
    +
    +## 13. Internal-graph types (unit specs) — important caveat 
    +
    +Several **unit** specs assert on the **internal CRDT graph**, not the public API:
    +`InternalLiveCounter.data`, `InternalLiveMap.siteTimeserials`, `ObjectsPool.syncState`, `LiveObjectUpdate`,
    +`applyOperation(msg, source)`, object-id generation (`RTO14`), the `*CreateWithObjectId` wire variants and
    +`generateObjectId`, etc. Specs that are wholly or mostly internal:
    +
    +- `objects_pool.md`, `parent_references.md`, `object_id.md` — pool sync state, the reverse parent-reference
    +  graph, and object-id generation: entirely internal.
    +- the internal-state assertions in `live_counter.md` / `live_map.md` (`.data`, `.siteTimeserials`,
    +  `.createOperationIsMerged`, `.isTombstone`, `applyOperation`, `replaceData`) — internal; their
    +  public-facing read/write counterparts live in `live_counter_api.md` / `live_map_api.md`.
    +- `value_types.md` — the *public* `LiveMap.create` / `LiveCounter.create` surface maps via §6, but the
    +  evaluation half (`COUNTER_CREATE` / `MAP_CREATE` `ObjectMessage` generation, nonce/`initialValue`/
    +  `objectId` derivation, the `*WithObjectId` wire forms) is internal/wire-level.
    +- `realtime_object.md` — **mixed**: `get()` (`RTO23`, incl. the `40024`/`90001`/`92008` precondition cases)
    +  is public and maps via §2/§12, but `publish` / `publishAndApply` (`RTO15`/`RTO20`, marked `internal` in the
    +  IDL) and the OBJECT/ACK wire assertions are internal.
    +- `public_object_message.md` — **mixed**: the `ObjectMessage` / `ObjectOperation` *getters* are public
    +  (assert them on a message received via `subscribe`, §11), but the explicit construction-from-wire it tests
    +  (`fromObjectMessage` / `fromObjectOperation`, `PAOM3`/`PAOOP3`) has no public factory and is internal.
    +
    +In ably-java these are **not public**. They live in the `:liveobjects` module as `Default*` / `Wire*` /
    +`ResolvedValue` / `Leaf` / `MapRef` / `CounterRef` classes (package `io.ably.lib.liveobjects.*`,
    +implementation source set) — and the `uts` module currently depends only on `:java` (public API) and
    +`:network-client-core`, **not** on `:liveobjects`. Consequences when translating:
    +
    +- **Public-API unit specs** (`path_object*.md`, `instance.md`, `live_object_subscribe.md`, and the
    +  public-surface parts of `realtime_object.md`, `public_object_message.md` and `value_types.md`) translate
    +  cleanly against the §1–§12 map and compile against `:java`. (Note `path_object.md` / `instance.md` also
    +  contain `compact()` cases, which are deviations per §4/§5 since ably-java implements only `compactJson()`.)
    +- **Internal-graph unit specs** can't be expressed through the public API. Before translating those, decide
    +  with the user whether to (a) add a `testImplementation(project(":liveobjects"))` dependency to
    +  `uts/build.gradle.kts` and target the `Default*`/internal classes directly, or (b) translate them inside
    +  the `:liveobjects` module's own test source set instead, or (c) skip them for the uts module. Flag this
    +  rather than forcing a public-API assertion that can't see internal state.
    +- Spec name → ably-java impl (for orientation, not public use): `InternalLiveMap` → `DefaultLiveMap`,
    +  `InternalLiveCounter` → `DefaultLiveCounter`, the public-view impls are `DefaultPathObject` /
    +  `DefaultLiveMapPathObject` / `DefaultInstance` / …, wire form is `WireObjectMessage` /
    +  `WireObjectOperation` / `WireObjectState` etc.
    +
    +---
    +
    +## 14. Worked example 
    +
    +Spec pseudocode (public-API style):
    +
    +```
    +test "increments a nested counter and observes it"
    +  root = AWAIT channel.object.get()
    +  AWAIT root.set("game", LiveMap.create({ score: LiveCounter.create(0) }))
    +  scoreSub = root.at("game.score").subscribe((event) => { received = event })
    +  AWAIT root.at("game.score").increment(10)
    +  ASSERT root.at("game.score").value() == 10
    +  ASSERT received.object.value() == 10
    +  scoreSub.unsubscribe()
    +```
    +
    +ably-java / Kotlin translation:
    +
    +```kotlin
    +val root: LiveMapPathObject = channel.`object`.get().await()   // `object` is a Kotlin keyword — backticks required
    +
    +root.set(
    +    "game",
    +    LiveMapValue.of(LiveMap.create(mapOf("score" to LiveMapValue.of(LiveCounter.create(0))))),
    +).await()
    +
    +var received: PathObjectSubscriptionEvent? = null
    +val scoreSub = root.at("game.score").subscribe(PathObjectListener { event -> received = event })
    +
    +root.at("game.score").asLiveCounter().increment(10).await()
    +
    +assertEquals(10.0, root.at("game.score").asLiveCounter().value())
    +assertEquals(10.0, received!!.getObject().asLiveCounter().value())
    +scoreSub.unsubscribe()
    +```
    +
    +Note the four mechanical rewrites: `get()` → `.await()`; nested `LiveMap.create`/`LiveCounter.create`
    +wrapped in `LiveMapValue.of`; `at(...)` followed by `asLiveCounter()` before counter ops; `event.object`
    +→ `event.getObject()` and re-cast.
    +
    +---
    +
    +## 15. Quick symbol index 
    +
    +| ably-js / spec symbol | ably-java |
    +|---|---|
    +| `channel.object` | field `` channel.`object` `` : `RealtimeObject` (Kotlin keyword → backticks) |
    +| `channel.object.get()` | `` channel.`object`.get() `` → `CompletableFuture` |
    +| `PathObject` (polymorphic) | base `PathObject` + `asLiveMap()`/`asLiveCounter()`/`as()` |
    +| `Instance` (polymorphic) | abstract `Instance` + `as*` (throwing) |
    +| `pathObj.get(k)` / `.at(p)` | `pathObj.asLiveMap().get(k)` / `.at(p)` |
    +| `pathObj.value()` | `pathObj.as().value()` (typed, null on mismatch) |
    +| `pathObj.set(k, v)` / `.remove(k)` | `pathObj.asLiveMap().set(k, LiveMapValue.of(v))` / `.remove(k)` |
    +| `pathObj.increment(n)` / `.decrement(n)` | `pathObj.asLiveCounter().increment(n)` / `.decrement(n)` |
    +| `op FAILS WITH ` (wrong method for type) | cast to the needed view, then assert the op throws: `assertFailsWith { node.asLiveCounter().increment(n).await() }` (§7, §12) |
    +| `FOR [k, v] IN x.entries()` | `for ((k, v) in x.asLiveMap().entries())` |
    +| `"k" IN x.keys()` / `list(x.keys())` | `"k" in x.asLiveMap().keys()` / `x.asLiveMap().keys().toList()` |
    +| `size() == 7` / `== null` | `assertEquals(7L, …size())` (Long) / `assertNull(node.asLiveMap().size())` |
    +| `op.action == "MAP_SET"` | `assertEquals(ObjectOperationAction.MAP_SET, op.action)` (string tag → enum) |
    +| `op.mapSet.value.string` | `op.mapSet!!.value.string` (ObjectData getters) |
    +| `LiveMap.create(entries)` | `LiveMap.create(Map)` (value type) |
    +| `LiveCounter.create(n)` | `LiveCounter.create(Number)` (value type) |
    +| raw value into `set` | `LiveMapValue.of(value)` |
    +| `subscribe(cb)` → `Subscription` | `subscribe(PathObjectListener / InstanceListener)` → `Subscription` |
    +| `{ depth: n }` | `PathObjectSubscriptionOptions(n)` |
    +| `event.object` / `event.message` | `event.getObject()` / `event.getMessage(): ObjectMessage?` |
    +| `object.on('synced', cb)` | `object.on(ObjectStateEvent.SYNCED, listener)` |
    +| type tag `'LiveMap'` etc. | `ValueType.LIVE_MAP` etc. |
    +| `PublicAPI::ObjectMessage` | `ObjectMessage` (getters) |
    +| `PublicAPI::ObjectOperation` | `ObjectOperation` (getters, one payload non-null) |
    +| `InternalLiveMap` / `InternalLiveCounter` / `ObjectsPool` | internal `:liveobjects` impl — see §13 |
    diff --git a/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py b/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py
    index fcc116dd2..a61d0d91c 100644
    --- a/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py
    +++ b/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py
    @@ -120,16 +120,29 @@ def main():
                      f"--create target {target!r} must be a simple module base name "
                      f"(letters/digits/underscore, e.g. 'liveobjects') so it forms a "
                      f"valid path and Kotlin package.")
    -        packages[source_module] = {
    +        new_entry = {
                 "unit": f"unit/{target}",
                 "integration": f"integration/standard/{target}",
                 "proxy": f"integration/proxy/{target}",
             }
    +        # preserve a hand-maintained "notes" pointer when re-creating an existing entry
    +        notes = packages.get(source_module, {}).get("notes")
    +        if notes:
    +            new_entry["notes"] = notes
    +        packages[source_module] = new_entry
             MAPPING.write_text(json.dumps(data, indent=2) + "\n")
     
         mapped = source_module in packages
         entry = packages.get(source_module, {})
     
    +    # Per-module translation notes (ably-js -> ably-java type map etc.), declared by
    +    # the module's "notes" field in the mapping (a path relative to this skill dir).
    +    # Read it before translating when present. None when the module declares no notes,
    +    # or the declared file is missing.
    +    notes_rel = entry.get("notes")
    +    notes_path = SKILL_DIR / notes_rel if notes_rel else None
    +    translation_notes = str(notes_path) if (notes_path and notes_path.is_file()) else None
    +
         src = {
             "unit": module_dir / "unit",
             "integration": module_dir / "integration",
    @@ -157,6 +170,7 @@ def main():
             "sourceModule": source_module,
             "mapped": mapped,
             "testRoot": test_root,
    +        "translationNotes": translation_notes,
             "tiers": tiers_out,
         }, indent=2))
     
    diff --git a/.claude/skills/uts-to-kotlin/uts-package-mapping.json b/.claude/skills/uts-to-kotlin/uts-package-mapping.json
    index fce1317db..6e3c8172a 100644
    --- a/.claude/skills/uts-to-kotlin/uts-package-mapping.json
    +++ b/.claude/skills/uts-to-kotlin/uts-package-mapping.json
    @@ -1,5 +1,5 @@
     {
    -  "_comment": "Maps each UTS spec module (a dir under specification/uts/) to its target test packages. Output dir = testRoot + '/' + tier entry; Kotlin package = that path after 'src/test/kotlin/' with '/' -> '.'. Used by the uts-to-kotlin skill.",
    +  "_comment": "Maps each UTS spec module (a dir under specification/uts/) to its target test packages. Output dir = testRoot + '/' + tier entry; Kotlin package = that path after 'src/test/kotlin/' with '/' -> '.'. An optional 'notes' field points (relative to this skill dir) to a per-module ably-js -> ably-java translation reference, read before translating that module. Used by the uts-to-kotlin skill.",
       "testRoot": "uts/src/test/kotlin/io/ably/lib/uts",
       "packages": {
         "realtime": {
    @@ -10,7 +10,8 @@
         "objects": {
           "unit": "unit/liveobjects",
           "integration": "integration/standard/liveobjects",
    -      "proxy": "integration/proxy/liveobjects"
    +      "proxy": "integration/proxy/liveobjects",
    +      "notes": "references/objects-mapping.md"
         },
         "rest": {
           "unit": "unit/rest",
    
    From 752b45ee5e9f7b8e796d032d5a0f81a1880c8096 Mon Sep 17 00:00:00 2001
    From: sacOO7 
    Date: Fri, 26 Jun 2026 01:15:10 +0530
    Subject: [PATCH 36/40] feat(uts): add LiveObjects unit-test helpers
     (standard_test_pool) + map them
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Translate objects/helpers/standard_test_pool.md into ably-java test helpers in
    uts/.../unit/liveobjects/helpers.kt: the standard object pool, the protocol- and
    object-message builders (emitting the integer-coded wire JSON the SDK's Gson
    expects), setupSyncedChannel/NoAck over the existing MockWebSocket, and
    buildPublicObjectMessage — which reaches the internal PAOM3/PAOOP3 construction
    (WireObjectMessage -> DefaultObjectMessage) by reflection, so it runs today even
    though the rest of :liveobjects is unimplemented. Add testRuntimeOnly(:liveobjects)
    so that reflection resolves while keeping the compile classpath decoupled.
    
    Wire the mapping reference at it: objects-mapping.md gains a "Unit-test helpers"
    section mapping each spec helper to its Kotlin name, and §11/§13 are corrected to
    note public_object_message.md is translatable via buildPublicObjectMessage rather
    than internal-only.
    ---
     .../references/objects-mapping.md             |  74 ++--
     uts/build.gradle.kts                          |   3 +
     .../ably/lib/uts/unit/liveobjects/helpers.kt  | 339 ++++++++++++++++++
     3 files changed, 395 insertions(+), 21 deletions(-)
     create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/helpers.kt
    
    diff --git a/.claude/skills/uts-to-kotlin/references/objects-mapping.md b/.claude/skills/uts-to-kotlin/references/objects-mapping.md
    index c30b9f34d..cbe977f0c 100644
    --- a/.claude/skills/uts-to-kotlin/references/objects-mapping.md
    +++ b/.claude/skills/uts-to-kotlin/references/objects-mapping.md
    @@ -395,12 +395,14 @@ delivered to subscription listeners) map to ably-java interfaces with getters (p
     `io.ably.lib.liveobjects.message`). The `PublicAPI::` prefix is dropped — ably-java exposes them as
     `ObjectMessage` / `ObjectOperation`.
     
    -> **Getter-only, no public constructor.** You obtain an `ObjectMessage` only from a subscription event
    -> (`event.getMessage()`, §8) — there is no public factory. The spec's *explicit* construction-from-wire
    -> (`PublicObjectMessage.fromObjectMessage(source, channel)` / `PublicObjectOperation.fromObjectOperation(op)`,
    -> `PAOM3`/`PAOOP3`, in `public_object_message.md`) is internal in ably-java, so assert the getters on a
    -> message received via `subscribe` rather than constructing one; treat a standalone construction-only test
    -> as internal (§13).
    +> **Getter-only, no *public* constructor — use the `buildPublicObjectMessage` helper.** In normal use you
    +> obtain an `ObjectMessage` from a subscription event (`event.getMessage()`, §8); there is no public
    +> factory. The spec's explicit construction-from-wire (`PublicObjectMessage.fromObjectMessage(source,
    +> channel)` / `PublicObjectOperation.fromObjectOperation(op)`, `PAOM3`/`PAOOP3`, in
    +> `public_object_message.md`) is `internal` to `:liveobjects` — but the unit helpers expose it **by
    +> reflection** as `buildPublicObjectMessage(wireJson, channelName)` (§13). So `public_object_message.md` is
    +> translatable: build the source with the op builders (`buildMapSet(...)`, `buildCounterInc(...)`, …) and
    +> assert the public getters on the result.
     
     `ObjectMessage`: `getId()`, `getClientId()`, `getConnectionId()`, `getTimestamp(): Long?`, `getChannel():
     String`, `getOperation(): ObjectOperation`, `getSerial()`, `getSerialTimestamp(): Long?`, `getSiteCode()`,
    @@ -499,29 +501,59 @@ Several **unit** specs assert on the **internal CRDT graph**, not the public API
     - `realtime_object.md` — **mixed**: `get()` (`RTO23`, incl. the `40024`/`90001`/`92008` precondition cases)
       is public and maps via §2/§12, but `publish` / `publishAndApply` (`RTO15`/`RTO20`, marked `internal` in the
       IDL) and the OBJECT/ACK wire assertions are internal.
    -- `public_object_message.md` — **mixed**: the `ObjectMessage` / `ObjectOperation` *getters* are public
    -  (assert them on a message received via `subscribe`, §11), but the explicit construction-from-wire it tests
    -  (`fromObjectMessage` / `fromObjectOperation`, `PAOM3`/`PAOOP3`) has no public factory and is internal.
    +- `public_object_message.md` — **translatable** via the `buildPublicObjectMessage` helper (below), which
    +  reflectively performs the `PAOM3`/`PAOOP3` construction (`WireObjectMessage` → `DefaultObjectMessage`)
    +  that is otherwise `internal`. Build the source with the op builders and assert the public getters (§11).
     
     In ably-java these are **not public**. They live in the `:liveobjects` module as `Default*` / `Wire*` /
     `ResolvedValue` / `Leaf` / `MapRef` / `CounterRef` classes (package `io.ably.lib.liveobjects.*`,
    -implementation source set) — and the `uts` module currently depends only on `:java` (public API) and
    -`:network-client-core`, **not** on `:liveobjects`. Consequences when translating:
    -
    -- **Public-API unit specs** (`path_object*.md`, `instance.md`, `live_object_subscribe.md`, and the
    -  public-surface parts of `realtime_object.md`, `public_object_message.md` and `value_types.md`) translate
    -  cleanly against the §1–§12 map and compile against `:java`. (Note `path_object.md` / `instance.md` also
    -  contain `compact()` cases, which are deviations per §4/§5 since ably-java implements only `compactJson()`.)
    -- **Internal-graph unit specs** can't be expressed through the public API. Before translating those, decide
    -  with the user whether to (a) add a `testImplementation(project(":liveobjects"))` dependency to
    -  `uts/build.gradle.kts` and target the `Default*`/internal classes directly, or (b) translate them inside
    -  the `:liveobjects` module's own test source set instead, or (c) skip them for the uts module. Flag this
    -  rather than forcing a public-API assertion that can't see internal state.
    +implementation source set). The `uts` module keeps them **off its compile classpath** (it compiles against
    +`:java` only) but now has `testRuntimeOnly(project(":liveobjects"))`, so the helpers reach the internal
    +wire/message classes **by reflection** at runtime. Consequences when translating:
    +
    +- **Public-API unit specs** (`path_object*.md`, `instance.md`, `live_object_subscribe.md`,
    +  `public_object_message.md`, and the public-surface parts of `realtime_object.md` and `value_types.md`)
    +  translate cleanly against the §1–§12 map + the helpers below, and compile against `:java`. (Note
    +  `path_object.md` / `instance.md` also contain `compact()` cases, which are deviations per §4/§5 since
    +  ably-java implements only `compactJson()`.)
    +- **Internal-graph unit specs** (`objects_pool.md`, `parent_references.md`, the internal-state assertions in
    +  `live_counter.md` / `live_map.md`) assert on internal CRDT state the public API can't see. Options: (a)
    +  add reflective accessors to the helpers for the `Default*`/internal classes (the technique
    +  `buildPublicObjectMessage` and `infra/unit/Utils.kt` already use), (b) translate them in the
    +  `:liveobjects` module's own test source where the types are directly accessible, or (c) skip them. Flag
    +  rather than forcing a public-API assertion that can't reach internal state.
     - Spec name → ably-java impl (for orientation, not public use): `InternalLiveMap` → `DefaultLiveMap`,
       `InternalLiveCounter` → `DefaultLiveCounter`, the public-view impls are `DefaultPathObject` /
       `DefaultLiveMapPathObject` / `DefaultInstance` / …, wire form is `WireObjectMessage` /
       `WireObjectOperation` / `WireObjectState` etc.
     
    +### Unit-test helpers — `standard_test_pool.md` → `helpers.kt`
    +
    +Every objects unit spec opens with `setup_synced_channel` and constructs protocol/object messages with the
    +`build_*` helpers. These are implemented in
    +`uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/helpers.kt` — **call them; don't hand-roll the mock
    +setup or message JSON.**
    +
    +| Spec helper | `helpers.kt` |
    +|---|---|
    +| `{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test")` | `val (client, channel, root, mockWs) = setupSyncedChannel("test")` (`suspend`, returns `SyncedChannel`) |
    +| `setup_synced_channel_no_ack(...)` | `setupSyncedChannelNoAck(...)` |
    +| `build_object_sync_message` / `build_object_message` / `build_ack_message` | `buildObjectSyncMessage` / `buildObjectMessage` / `buildAckMessage` → `ProtocolMessage` |
    +| `build_counter_inc` / `build_map_set` / `build_map_remove` / `build_map_clear` / `build_object_delete` / `build_counter_create` / `build_map_create` | same names camelCased → wire `JsonObject` |
    +| `build_object_state` / `build_object_message_with_state` | `buildObjectState` / `buildObjectMessageWithState` |
    +| `build_public_object_message(msg, channel)` | `buildPublicObjectMessage(wireJson, channel)` (reflective; §11) |
    +| `STANDARD_POOL_OBJECTS` | `STANDARD_POOL_OBJECTS` |
    +| inline ObjectData / map-entry / state fragments | `dataString` / `dataNumber` / `dataBoolean` / `dataObjectId` / `dataBytes` / `dataJson`, `mapEntry`, `mapState`, `counterState`, `mapCreateOp`, `counterCreateOp` |
    +
    +`mock_ws.send_to_client(...)` is the existing `mockWs.sendToClient(...)` (§ mock API in the main skill). The
    +wire `action` / `semantics` are integer enum codes — the builders emit the codes for you.
    +
    +> **Runtime caveat:** `setupSyncedChannel` returns only once `RealtimeObject.get()` resolves, which needs
    +> the `:liveobjects` SDK's OBJECT_SYNC processing. Until that lands the helpers **compile** and the test
    +> structure is correct, but the setup throws at runtime — i.e. translate-only today, runnable once the SDK
    +> is implemented. (`buildPublicObjectMessage` does *not* depend on this — the message/operation layer is
    +> implemented, so those tests can run now.)
    +
     ---
     
     ## 14. Worked example 
    diff --git a/uts/build.gradle.kts b/uts/build.gradle.kts
    index 4638ac1b3..5286e59eb 100644
    --- a/uts/build.gradle.kts
    +++ b/uts/build.gradle.kts
    @@ -7,6 +7,9 @@ plugins {
     dependencies {
         testImplementation(project(":java"))
         testImplementation(project(":network-client-core"))
    +    // Runtime-only so compile-time stays decoupled from the plugin internals; the LiveObjects test
    +    // helpers reach the internal wire/message classes (e.g. for build_public_object_message) by reflection.
    +    testRuntimeOnly(project(":liveobjects"))
         testImplementation(kotlin("test"))
         testImplementation(libs.mockk)
         testImplementation(libs.coroutine.core)
    diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/helpers.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/helpers.kt
    new file mode 100644
    index 000000000..0830539e0
    --- /dev/null
    +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/helpers.kt
    @@ -0,0 +1,339 @@
    +package io.ably.lib.uts.unit.liveobjects
    +
    +import com.google.gson.JsonElement
    +import com.google.gson.JsonObject
    +import com.google.gson.JsonParser
    +import io.ably.lib.liveobjects.message.ObjectMessage
    +import io.ably.lib.liveobjects.path.types.LiveMapPathObject
    +import io.ably.lib.realtime.AblyRealtime
    +import io.ably.lib.realtime.Channel
    +import io.ably.lib.types.ChannelMode
    +import io.ably.lib.types.ChannelOptions
    +import io.ably.lib.types.ProtocolMessage
    +import io.ably.lib.types.PublishResult
    +import io.ably.lib.uts.infra.unit.ConnectionDetails
    +import io.ably.lib.uts.infra.unit.MockWebSocket
    +import io.ably.lib.uts.infra.unit.TestRealtimeClient
    +import kotlinx.coroutines.future.await
    +
    +/**
    + * LiveObjects unit-test helpers — the ably-java translation of the UTS
    + * `objects/helpers/standard_test_pool.md` (standard test pool, protocol-message /
    + * object-message builders, and the synced-channel setup) used by every objects
    + * unit spec.
    + *
    + * Status:
    + *  - The builders construct the **wire JSON** form (Gson [JsonObject]) of object messages and drop them
    + *    into [ProtocolMessage.state] (`Object[]`); the file compiles against `:java` only (no *compile-time*
    + *    `:liveobjects` dependency).
    + *  - [buildPublicObjectMessage] reaches the implemented message/operation layer in `:liveobjects` by
    + *    reflection (`testRuntimeOnly(project(":liveobjects"))` in build.gradle.kts), so PAOM3/PAOOP3
    + *    construction tests run today.
    + *  - [setupSyncedChannel] drives CONNECTED -> ATTACH/ATTACHED(HAS_OBJECTS) -> OBJECT_SYNC over the existing
    + *    [MockWebSocket], then awaits `channel.object.get()`. That last step needs the SDK's OBJECT_SYNC
    + *    processing + `RealtimeObject.get()`, both still TODO — so the generated tests **compile** now and
    + *    become **runnable** once the SDK lands (translate-only until then).
    + */
    +
    +// ---------------------------------------------------------------------------
    +// small Gson DSL
    +// ---------------------------------------------------------------------------
    +
    +private fun json(build: JsonObject.() -> Unit): JsonObject = JsonObject().apply(build)
    +private fun JsonObject.str(key: String, value: String) = addProperty(key, value)
    +private fun JsonObject.num(key: String, value: Number) = addProperty(key, value)
    +private fun JsonObject.bool(key: String, value: Boolean) = addProperty(key, value)
    +
    +// ---------------------------------------------------------------------------
    +// ObjectData (leaf value) wire builders — the `data` of a map entry / mapSet
    +// ---------------------------------------------------------------------------
    +
    +fun dataString(value: String): JsonObject = json { str("string", value) }
    +fun dataNumber(value: Number): JsonObject = json { num("number", value) }
    +fun dataBoolean(value: Boolean): JsonObject = json { bool("boolean", value) }
    +fun dataObjectId(objectId: String): JsonObject = json { str("objectId", objectId) }
    +fun dataBytes(base64: String): JsonObject = json { str("bytes", base64) }
    +fun dataJson(element: JsonElement): JsonObject = json { add("json", element) }
    +
    +// ---------------------------------------------------------------------------
    +// map / counter state + createOp fragments
    +// ---------------------------------------------------------------------------
    +
    +fun mapEntry(data: JsonObject, timeserial: String = "t:0", tombstone: Boolean? = null): JsonObject = json {
    +    add("data", data)
    +    str("timeserial", timeserial)
    +    tombstone?.let { bool("tombstone", it) }
    +}
    +
    +/**
    + * Wire enum codes — the objects JSON protocol carries `action` / `semantics` as integer codes
    + * (`WireObjectOperationAction` / `WireObjectsMapSemantics`), not strings. The SDK's Gson decodes them by
    + * code, so the builders must emit the code for messages to deserialize.
    + */
    +private object Action {
    +    const val MAP_CREATE = 0
    +    const val MAP_SET = 1
    +    const val MAP_REMOVE = 2
    +    const val COUNTER_CREATE = 3
    +    const val COUNTER_INC = 4
    +    const val OBJECT_DELETE = 5
    +    const val MAP_CLEAR = 6
    +}
    +private const val SEMANTICS_LWW = 0
    +
    +fun mapState(entries: Map, semantics: Int = SEMANTICS_LWW): JsonObject = json {
    +    num("semantics", semantics)
    +    add("entries", json { entries.forEach { (k, v) -> add(k, v) } })
    +}
    +
    +fun counterState(count: Number): JsonObject = json { num("count", count) }
    +
    +fun mapCreateOp(semantics: Int = SEMANTICS_LWW, entries: Map = emptyMap()): JsonObject =
    +    json { num("action", Action.MAP_CREATE); add("mapCreate", mapState(entries, semantics)) }
    +
    +fun counterCreateOp(count: Number): JsonObject =
    +    json { num("action", Action.COUNTER_CREATE); add("counterCreate", json { num("count", count) }) }
    +
    +// ---------------------------------------------------------------------------
    +// ObjectMessage builders — STATE (for OBJECT_SYNC) and OPERATIONS (for OBJECT)
    +// ---------------------------------------------------------------------------
    +
    +/** `build_object_state` — an ObjectMessage wrapping an ObjectState in its `object` field. */
    +fun buildObjectState(
    +    objectId: String,
    +    siteTimeserials: Map,
    +    map: JsonObject? = null,
    +    counter: JsonObject? = null,
    +    tombstone: Boolean? = null,
    +    createOp: JsonObject? = null,
    +): JsonObject = json {
    +    add(
    +        "object",
    +        json {
    +            str("objectId", objectId)
    +            add("siteTimeserials", json { siteTimeserials.forEach { (k, v) -> str(k, v) } })
    +            map?.let { add("map", it) }
    +            counter?.let { add("counter", it) }
    +            bool("tombstone", tombstone ?: false) // WireObjectState.tombstone is non-nullable
    +            createOp?.let { add("createOp", it) }
    +        },
    +    )
    +}
    +
    +/**
    + * `build_object_message_with_state` — wraps an already-built ObjectState (the inner `object` payload) in
    + * an ObjectMessage. [buildObjectState] builds the state and wraps it in one step; this is the wrap-only
    + * form used where a bare ObjectState needs to become an ObjectMessage (e.g. `replaceData`).
    + */
    +fun buildObjectMessageWithState(objectState: JsonObject): JsonObject = json { add("object", objectState) }
    +
    +private fun objectMessage(
    +    serial: String?,
    +    siteCode: String?,
    +    serialTimestamp: Long? = null,
    +    operation: JsonObject,
    +): JsonObject = json {
    +    serial?.let { str("serial", it) }
    +    siteCode?.let { str("siteCode", it) }
    +    serialTimestamp?.let { num("serialTimestamp", it) }
    +    add("operation", operation)
    +}
    +
    +fun buildCounterInc(objectId: String, number: Number, serial: String? = null, siteCode: String? = null): JsonObject =
    +    objectMessage(serial, siteCode, operation = json {
    +        num("action", Action.COUNTER_INC); str("objectId", objectId); add("counterInc", json { num("number", number) })
    +    })
    +
    +fun buildMapSet(objectId: String, key: String, value: JsonObject, serial: String? = null, siteCode: String? = null): JsonObject =
    +    objectMessage(serial, siteCode, operation = json {
    +        num("action", Action.MAP_SET); str("objectId", objectId)
    +        add("mapSet", json { str("key", key); add("value", value) })
    +    })
    +
    +fun buildMapRemove(objectId: String, key: String, serial: String? = null, siteCode: String? = null, serialTimestamp: Long? = null): JsonObject =
    +    objectMessage(serial, siteCode, serialTimestamp, operation = json {
    +        num("action", Action.MAP_REMOVE); str("objectId", objectId); add("mapRemove", json { str("key", key) })
    +    })
    +
    +fun buildMapClear(objectId: String, serial: String? = null, siteCode: String? = null): JsonObject =
    +    objectMessage(serial, siteCode, operation = json {
    +        num("action", Action.MAP_CLEAR); str("objectId", objectId)
    +    })
    +
    +fun buildObjectDelete(objectId: String, serial: String? = null, siteCode: String? = null, serialTimestamp: Long? = null): JsonObject =
    +    objectMessage(serial, siteCode, serialTimestamp, operation = json {
    +        num("action", Action.OBJECT_DELETE); str("objectId", objectId)
    +    })
    +
    +fun buildCounterCreate(objectId: String, counterCreate: JsonObject, serial: String? = null, siteCode: String? = null): JsonObject =
    +    objectMessage(serial, siteCode, operation = json {
    +        num("action", Action.COUNTER_CREATE); str("objectId", objectId); add("counterCreate", counterCreate)
    +    })
    +
    +fun buildMapCreate(objectId: String, mapCreate: JsonObject, serial: String? = null, siteCode: String? = null): JsonObject =
    +    objectMessage(serial, siteCode, operation = json {
    +        num("action", Action.MAP_CREATE); str("objectId", objectId); add("mapCreate", mapCreate)
    +    })
    +
    +// ---------------------------------------------------------------------------
    +// ProtocolMessage builders
    +// ---------------------------------------------------------------------------
    +
    +private fun List.asState(): Array = Array(size) { this[it] }
    +
    +fun buildObjectSyncMessage(channel: String, channelSerial: String, objectMessages: List): ProtocolMessage =
    +    ProtocolMessage(ProtocolMessage.Action.object_sync).apply {
    +        this.channel = channel
    +        this.channelSerial = channelSerial
    +        state = objectMessages.asState()
    +    }
    +
    +fun buildObjectMessage(channel: String, objectMessages: List): ProtocolMessage =
    +    ProtocolMessage(ProtocolMessage.Action.`object`).apply {
    +        this.channel = channel
    +        state = objectMessages.asState()
    +    }
    +
    +fun buildAckMessage(msgSerial: Long?, serials: List): ProtocolMessage =
    +    ProtocolMessage(ProtocolMessage.Action.ack).apply {
    +        this.msgSerial = msgSerial
    +        res = arrayOf(PublishResult(serials.toTypedArray()))
    +    }
    +
    +/**
    + * `build_public_object_message` — constructs a public [ObjectMessage] (PAOM3) from the wire form of an
    + * object message (as produced by the operation builders above) and a channel name.
    + *
    + * ably-java's public `ObjectMessage` / `ObjectOperation` are getter-only interfaces with no public factory
    + * — the construction (`WireObjectMessage` -> `DefaultObjectMessage`) lives `internal` in `:liveobjects`.
    + * We reach it by **reflection**, in the same spirit as `infra/unit/Utils.kt` (which reflectively reaches an
    + * inaccessible `:java` member) — but here the classes are on the *runtime-only* classpath
    + * (`testRuntimeOnly(project(":liveobjects"))`), so we load them with `Class.forName` rather than flipping
    + * `isAccessible`. The targeted members compile to plain `public` on the JVM (Kotlin `internal` is not
    + * name-mangled here), so they are addressable by their declared names:
    + *   - `JsonSerializationKt.toObjectMessage(JsonObject): WireObjectMessage` (Gson, decodes enum codes)
    + *   - `DefaultObjectMessage(WireObjectMessage, String)`
    + */
    +fun buildPublicObjectMessage(objectMessage: JsonObject, channelName: String): ObjectMessage {
    +    val serializationKt = Class.forName("io.ably.lib.liveobjects.serialization.JsonSerializationKt")
    +    val toWire = serializationKt.getMethod("toObjectMessage", JsonObject::class.java)
    +    val wire = toWire.invoke(null, objectMessage)
    +
    +    val wireClass = Class.forName("io.ably.lib.liveobjects.message.WireObjectMessage")
    +    val defaultMessage = Class.forName("io.ably.lib.liveobjects.message.DefaultObjectMessage")
    +        .getConstructor(wireClass, String::class.java)
    +        .newInstance(wire, channelName)
    +    return defaultMessage as ObjectMessage
    +}
    +
    +// `provision_objects_via_rest(...)` is intentionally not here — it's REST fixture provisioning for
    +// *integration* tests and belongs in the integration infra, not this unit helper file.
    +
    +// ---------------------------------------------------------------------------
    +// STANDARD_POOL_OBJECTS — the fixed tree shared by all objects unit specs
    +// ---------------------------------------------------------------------------
    +
    +private val SITE = mapOf("aaa" to "t:0")
    +
    +val STANDARD_POOL_OBJECTS: List = listOf(
    +    buildObjectState(
    +        "root", SITE,
    +        map = mapState(
    +            linkedMapOf(
    +                "name" to mapEntry(dataString("Alice")),
    +                "age" to mapEntry(dataNumber(30)),
    +                "active" to mapEntry(dataBoolean(true)),
    +                "score" to mapEntry(dataObjectId("counter:score@1000")),
    +                "profile" to mapEntry(dataObjectId("map:profile@1000")),
    +                "data" to mapEntry(dataJson(JsonParser.parseString("""{"tags":["a","b"]}"""))),
    +                "avatar" to mapEntry(dataBytes("AQID")),
    +            ),
    +        ),
    +        createOp = mapCreateOp(),
    +    ),
    +    buildObjectState("counter:score@1000", SITE, counter = counterState(100), createOp = counterCreateOp(100)),
    +    buildObjectState(
    +        "map:profile@1000", SITE,
    +        map = mapState(
    +            linkedMapOf(
    +                "email" to mapEntry(dataString("alice@example.com")),
    +                "nested_counter" to mapEntry(dataObjectId("counter:nested@1000")),
    +                "prefs" to mapEntry(dataObjectId("map:prefs@1000")),
    +            ),
    +        ),
    +        createOp = mapCreateOp(),
    +    ),
    +    buildObjectState("counter:nested@1000", SITE, counter = counterState(5), createOp = counterCreateOp(5)),
    +    buildObjectState(
    +        "map:prefs@1000", SITE,
    +        map = mapState(linkedMapOf("theme" to mapEntry(dataString("dark")))),
    +        createOp = mapCreateOp(),
    +    ),
    +)
    +
    +// ---------------------------------------------------------------------------
    +// synced-channel setup
    +// ---------------------------------------------------------------------------
    +
    +/** Result of [setupSyncedChannel] — the spec's `{ client, channel, root, mock_ws }`. */
    +data class SyncedChannel(
    +    val client: AblyRealtime,
    +    val channel: Channel,
    +    val root: LiveMapPathObject,
    +    val mockWs: MockWebSocket,
    +)
    +
    +/** `setup_synced_channel` — connected client + channel synced with [STANDARD_POOL_OBJECTS]; auto-ACKs OBJECT publishes. */
    +suspend fun setupSyncedChannel(channelName: String): SyncedChannel = setup(channelName, autoAck = true)
    +
    +/** `setup_synced_channel_no_ack` — as above but does not ACK OBJECT publishes (for tests that control ACK timing). */
    +suspend fun setupSyncedChannelNoAck(channelName: String): SyncedChannel = setup(channelName, autoAck = false)
    +
    +private suspend fun setup(channelName: String, autoAck: Boolean): SyncedChannel {
    +    lateinit var mockWs: MockWebSocket
    +    mockWs = MockWebSocket {
    +        onConnectionAttempt = { conn ->
    +            conn.respondWithSuccess(
    +                ProtocolMessage(ProtocolMessage.Action.connected).apply {
    +                    connectionId = "conn-1"
    +                    connectionDetails = ConnectionDetails {
    +                        connectionKey = "conn-key-1"
    +                        siteCode = "test-site"
    +                        objectsGCGracePeriod = 86_400_000L
    +                    }
    +                },
    +            )
    +        }
    +        onMessageFromClient = { msg ->
    +            when (msg.action) {
    +                ProtocolMessage.Action.attach -> {
    +                    mockWs.sendToClient(
    +                        ProtocolMessage(ProtocolMessage.Action.attached).apply {
    +                            channel = msg.channel
    +                            channelSerial = "sync1:"
    +                            setFlag(ProtocolMessage.Flag.has_objects)
    +                        },
    +                    )
    +                    mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS))
    +                }
    +                ProtocolMessage.Action.`object` -> if (autoAck) {
    +                    val serials = (msg.state?.indices ?: IntRange.EMPTY).map { "ack-${msg.msgSerial}:$it" }
    +                    mockWs.sendToClient(buildAckMessage(msg.msgSerial, serials))
    +                }
    +                else -> Unit
    +            }
    +        }
    +    }
    +
    +    val client = TestRealtimeClient {
    +        key = "fake:key"
    +        install(mockWs)
    +    }
    +    val channel = client.channels.get(
    +        channelName,
    +        ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) },
    +    )
    +    // NOTE: throws until :liveobjects implements RealtimeObject.get() + OBJECT_SYNC processing.
    +    val root = channel.`object`.get().await()
    +    return SyncedChannel(client, channel, root, mockWs)
    +}
    
    From 0c59d15c7a72577ccac2c7a59dda3d7d1abee801 Mon Sep 17 00:00:00 2001
    From: sacOO7 
    Date: Mon, 29 Jun 2026 23:40:27 +0530
    Subject: [PATCH 37/40] feat(uts): realtime direct-sandbox integration tests +
     sandbox host refactor
    
    - Add ChannelHistoryTest (RTL10d) and TokenRequestTest (RSA9/RSA9a/RSA9g)
      direct-sandbox integration tests, parameterised over json/msgpack.
    - Add junit-jupiter-params dependency (@ParameterizedTest / @ValueSource).
    - Consolidate the sandbox host into a single SandboxApp.sandboxHost constant
      (removed from ProxyManager; ProxySession defaults both hosts to it).
    - Sync uts/README.md, uts/index.html, and the uts-to-kotlin SKILL.md for the
      direct-sandbox tier: new ChannelHistoryTest walkthrough, integration-tests
      umbrella section, section renumber.
    - Rename unit liveobjects helpers.kt -> Helpers.kt (case-only).
    ---
     .claude/skills/uts-to-kotlin/SKILL.md         |  91 ++++++++--
     uts/README.md                                 | 156 ++++++++++++++----
     uts/build.gradle.kts                          |   2 +
     uts/index.html                                | 119 +++++++++----
     .../lib/uts/infra/integration/SandboxApp.kt   |  10 +-
     .../infra/integration/proxy/ProxyManager.kt   |   4 -
     .../infra/integration/proxy/ProxySession.kt   |   5 +-
     .../standard/realtime/ChannelHistoryTest.kt   | 140 ++++++++++++++++
     .../standard/realtime/TokenRequestTest.kt     | 120 ++++++++++++++
     .../liveobjects/{helpers.kt => Helpers.kt}    |   0
     10 files changed, 554 insertions(+), 93 deletions(-)
     create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/ChannelHistoryTest.kt
     create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt
     rename uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/{helpers.kt => Helpers.kt} (100%)
    
    diff --git a/.claude/skills/uts-to-kotlin/SKILL.md b/.claude/skills/uts-to-kotlin/SKILL.md
    index ca1cda668..7f24cb130 100644
    --- a/.claude/skills/uts-to-kotlin/SKILL.md
    +++ b/.claude/skills/uts-to-kotlin/SKILL.md
    @@ -82,8 +82,8 @@ Offer the tiers whose `present` is `true`. The chosen tier fixes the `targetDir`
     | Tier | Translation flow |
     |---|---|
     | **unit** | mocked transport — Steps 3–4 below |
    -| **integration** (direct sandbox) | real sandbox, no faults — **Proxy integration tests** section, but drop the `ProxySession`/`connectThroughProxy` wiring |
    -| **proxy** | real sandbox + fault injection — **Proxy integration tests** section |
    +| **integration** (direct sandbox) | real sandbox, no faults — **Direct-sandbox integration tests** section |
    +| **proxy** | real sandbox + fault injection — **Integration tests** section (proxy subsections) |
     
     ## Step D — Choose which specs to translate
     
    @@ -130,6 +130,7 @@ Then read the current spec file (the one being translated from the Step D select
     - All test cases — each has a structured ID like `realtime/unit/RSA4c2/callback-error-connecting-disconnected-0` and a description
     - The protocol used (WebSocket for Realtime, HTTP for REST)
     - Any timer usage (`enable_fake_timers`, `ADVANCE_TIME`)
    +- Any **protocol-variant dimension** — a `PROTOCOL` (`json` / `msgpack`) matrix the spec header says to run "once per variant". In ably-java this becomes a `useBinaryProtocol` `@ParameterizedTest` (see the **Direct-sandbox integration tests** section), not a plain `@Test`.
     
     ---
     
    @@ -141,19 +142,24 @@ list. Write the test to `/.kt` with `package ` at
     
     The spec's own `` grouping (e.g. `connection/`, `channels/`) is **not** reflected in the output — every
     test sits directly in `targetDir` (the resolver flattens it). The chosen tier also fixes the translation
    -flow: **unit** → the rules in Steps 3–4 below; **integration** (direct sandbox) and **proxy** → the **Proxy
    -integration tests** section (direct sandbox drops the `ProxySession`/`connectThroughProxy` wiring; see
    -Step C).
    +flow: **unit** → the rules in Steps 3–4 below; **integration** (direct sandbox) → the **Direct-sandbox
    +integration tests** section; **proxy** → the proxy subsections of the **Integration tests** section.
     
     ---
     
     ## Step 3 — Read infrastructure files
     
    +> **Orientation — read `uts/README.md` first.** It's the human-readable guide to this module: the
    +> tier model (unit / direct-sandbox / proxy), the per-tier Gradle tasks, the test-layout convention,
    +> and a file-map of every infra helper with its public surface (Appendix B). Skim it for the *why* and
    +> the *what's available*; the per-file list below is the *what to open for exact signatures* before
    +> writing code.
    +
     Infrastructure is split by tier under `uts/src/test/kotlin/io/ably/lib/uts/infra/`:
     
     - `infra/Utils.kt` — shared async helpers (`awaitState`, `awaitChannelState`, `pollUntil`), package `io.ably.lib.uts.infra`.
     - `infra/unit/` — unit-test mocks/factories (`ClientFactories.kt`, `MockWebSocket.kt`, `MockHttpClient.kt`, `FakeClock.kt`, `MockEvent.kt`, the `PendingConnection`/`PendingRequest` pairs, and `Utils.kt` with the `ConnectionDetails { }` builder), package `io.ably.lib.uts.infra.unit`.
    -- `infra/integration/` + `infra/integration/proxy/` — proxy/sandbox helpers (`SandboxApp.kt`, `ProxyManager.kt`, `ProxySession.kt`) — see the **Proxy integration tests** section.
    +- `infra/integration/` + `infra/integration/proxy/` — direct-sandbox + proxy helpers (`SandboxApp.kt`, `ProxyManager.kt`, `ProxySession.kt`) — see the **Integration tests** section.
     
     For a **unit** test, read all files under `infra/unit/` plus `infra/Utils.kt` before generating any code (you need exact method signatures).
     
    @@ -514,13 +520,18 @@ before finishing.
     
     ---
     
    -## Proxy integration tests
    +## Integration tests (direct sandbox + proxy)
    +
    +Some specs are **integration tests** — they run against the **real Ably sandbox** instead of a mocked transport. Two tiers share one foundation and differ only in *transport*:
     
    -Some specs are **integration tests** that exercise fault-handling behaviour against the **real Ably sandbox** instead of a mocked transport. They route the SDK through the [`ably/uts-proxy`](https://github.com/ably/uts-proxy) — a programmable HTTP/WebSocket proxy that forwards traffic transparently by default but can inject faults (dropped connections, modified/injected/delayed frames, error responses) via rules.
    +- **Direct sandbox** — the client connects straight to the sandbox. Happy-path interop (connect, publish, subscribe, presence); no faults.
    +- **Proxy** — the client is routed through the [`ably/uts-proxy`](https://github.com/ably/uts-proxy), a programmable HTTP/WebSocket proxy that forwards traffic transparently but can inject faults (dropped connections, modified/injected/delayed frames, error responses) via rules.
     
    -Recognise them by: a reference to `create_proxy_session()`, proxy `rules`, `trigger_action`, `get_log`, or a pointer to `uts/realtime/integration/helpers/proxy.md`.
    +**Shared foundation (both tiers, covered once):** `SandboxApp` provisioning + the `@BeforeAll`/`@AfterAll` lifecycle (see **Infrastructure** below), `runTest` test bodies, suspend-function handling, and the `awaitState` / `awaitChannelState` / `pollUntil` waits — never a fixed sleep, since real network is involved (use generous 10–30s timeouts). Only the *wiring* differs per tier; that's what the two tier subsections below cover.
     
    -### When proxy tests are the right tool
    +Recognise a **proxy** spec by a reference to `create_proxy_session()`, proxy `rules`, `trigger_action`, `get_log`, or a pointer to `uts/realtime/integration/helpers/proxy.md`. A spec with none of those is **direct sandbox**.
    +
    +### Which integration tier?
     
     | Test type | When the spec uses it |
     |---|---|
    @@ -528,19 +539,69 @@ Recognise them by: a reference to `create_proxy_session()`, proxy `rules`, `trig
     | **Direct sandbox integration** | Happy-path behaviour (connect, publish, subscribe, presence). No fault injection. |
     | **Proxy integration test** | Fault behaviour against the real backend: connection failures, resume, heartbeat starvation, token renewal under network errors, channel error injection. |
     
    +### Direct-sandbox integration tests
    +
    +A **direct-sandbox** spec (no `create_proxy_session`, no rules — just happy-path interop against `nonprod:sandbox`) uses the same `SandboxApp` provisioning and the same `runTest` / `@BeforeAll`+`runBlocking` lifecycle as a proxy test, but **drops all proxy wiring**: no `ProxyManager.ensureProxy()`, no `ProxySession`, no `connectThroughProxy`. The client connects straight to the sandbox host. `ChannelHistoryTest` (realtime) and `ObjectsLifecycleTest` (liveobjects) are the reference examples — read one before translating a direct-sandbox spec.
    +
    +**Client wiring** — point both transports at the sandbox host (explicit hosts auto-disable fallback hosts, so no `fallbackHosts`):
    +
    +```kotlin
    +private fun newClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeClient {
    +    key = app.defaultKey
    +    realtimeHost = SandboxApp.sandboxHost   // sandbox.realtime.ably-nonprod.net
    +    restHost     = SandboxApp.sandboxHost
    +    this.useBinaryProtocol = useBinaryProtocol
    +    autoConnect  = false
    +}
    +```
    +
    +**Class docstring** — use a direct-sandbox variant (drop the proxy/fault-injection wording):
    +
    +```kotlin
    +/**
    + * Direct-sandbox integration test against the Ably Sandbox (`sandbox.realtime.ably-nonprod.net`,
    + * via SandboxApp.sandboxHost) — no proxy, no fault injection. Provisions one throwaway SandboxApp
    + * for the suite and connects real clients straight to the sandbox.
    + */
    +```
    +
    +**Protocol variants (`json` / `msgpack`)** — when the spec header declares a `PROTOCOL` dimension and says each test runs once per variant, translate it to a `useBinaryProtocol` `@ParameterizedTest` (this is what the module's `junit-jupiter-params` dependency is for), not a plain `@Test`. The `@UTS` tag and method name stay singular — the parameter expresses the variant:
    +
    +```kotlin
    +/** @UTS realtime/integration/RTL10d/history-cross-client-0 */
    +@ParameterizedTest(name = "useBinaryProtocol={0}")
    +@ValueSource(booleans = [false, true])   // false = JSON, true = msgpack
    +fun `RTL10d - history contains messages published by another client`(useBinaryProtocol: Boolean) = runTest {
    +    val publisher = newClient(useBinaryProtocol)
    +    // …
    +}
    +```
    +
    +Import `org.junit.jupiter.params.ParameterizedTest` and `org.junit.jupiter.params.provider.ValueSource`. A spec with **no** protocol dimension stays a plain `@Test`.
    +
    +**Awaiting real server outcomes** — integration specs assert on real backend state, so never sleep; await or poll:
    +
    +| Pseudocode | Kotlin |
    +|---|---|
    +| `AWAIT channel.attach()` | `channel.attach()` then `awaitChannelState(channel, ChannelState.attached, 10.seconds)` |
    +| `AWAIT channel.publish(name, data)` (await the ack) | wrap the **non-deprecated** `publish(name, data, Callback)` overload in `suspendCancellableCoroutine` — resume on `onSuccess`, fail on `onError` (the `CompletionListener` overload is deprecated) |
    +| `poll_until(() => AWAIT channel.history().items.length == N, …)` | `pollUntil(10.seconds, 500.milliseconds) { channel.history(null).items().size == N }` (`history()` is a blocking REST call; `null` = no params) |
    +
    +Use generous timeouts (10–30s) — real network is involved. Everything else is the shared foundation described at the top of this section; a direct-sandbox test just skips the proxy-only subsections (`ProxySession`, rule factories, the event log).
    +
     ### Infrastructure
     
    -Three helpers live under `uts/src/test/kotlin/io/ably/lib/uts/infra/integration/`. **Read them before translating a proxy spec** — they hold the exact method signatures.
    +Three helpers live under `uts/src/test/kotlin/io/ably/lib/uts/infra/integration/`. **Read the ones your tier uses before translating an integration spec** — they hold the exact method signatures. `SandboxApp` serves **both** tiers; `ProxyManager` and `ProxySession` are **proxy-only**.
     
    -- **`ProxyManager`** (`infra/integration/proxy/ProxyManager.kt`, package `io.ably.lib.uts.infra.integration.proxy`) — downloads/starts the shared `uts-proxy` process and exposes the sandbox host. Call `ProxyManager.ensureProxy()` once per suite in setup. `ProxyManager.sandboxRealtimeHost` / `sandboxRestHost` are the upstream sandbox hosts (the default target of every session).
    +- **`ProxyManager`** (`infra/integration/proxy/ProxyManager.kt`, package `io.ably.lib.uts.infra.integration.proxy`) — downloads/starts the shared `uts-proxy` process. Call `ProxyManager.ensureProxy()` once per suite in setup.
     - **`ProxySession`** (`infra/integration/proxy/ProxySession.kt`, same package) — one programmable session wrapping the proxy control API; also defines the `connectThroughProxy` extension and the rule-builder helpers.
    -- **`SandboxApp`** (`infra/integration/SandboxApp.kt`, package `io.ably.lib.uts.infra.integration`) — provisions/deletes a sandbox test app from the shared `test-app-setup.json` in ably-common. `SandboxApp.create()` returns a `SandboxApp` with `appId`, `defaultKey`, and `keys` (`defaultKey` is a full-capability `appId.keyId:keySecret`); `app.delete()` tears it down. Provision in suite setup, delete in teardown.
    +- **`SandboxApp`** (`infra/integration/SandboxApp.kt`, package `io.ably.lib.uts.infra.integration`) — provisions/deletes a sandbox test app from the shared `test-app-setup.json` in ably-common. `SandboxApp.create()` returns a `SandboxApp` with `appId`, `defaultKey`, and `keys` (`defaultKey` is a full-capability `appId.keyId:keySecret`); `app.delete()` tears it down. Provision in suite setup, delete in teardown. Also owns the single upstream sandbox host constant `SandboxApp.sandboxHost` (`sandbox.realtime.ably-nonprod.net`, the resolved `nonprod:sandbox` endpoint) — the default target of every `ProxySession` (both `realtimeHost` and `restHost`), and what direct-sandbox clients set `realtimeHost` / `restHost` from.
     
    -Import these into a proxy test from their packages, e.g. `io.ably.lib.uts.infra.integration.SandboxApp`, `io.ably.lib.uts.infra.integration.proxy.{ProxyManager, ProxySession, connectThroughProxy}`, plus `io.ably.lib.uts.infra.unit.TestRealtimeClient` and `io.ably.lib.uts.infra.{awaitState, pollUntil}`.
    +Import what the tier needs: a **direct-sandbox** test imports `io.ably.lib.uts.infra.integration.SandboxApp` plus `io.ably.lib.uts.infra.unit.TestRealtimeClient` and `io.ably.lib.uts.infra.{awaitState, pollUntil}`; a **proxy** test additionally imports `io.ably.lib.uts.infra.integration.proxy.{ProxyManager, ProxySession, connectThroughProxy}`.
     
     `ensureProxy()`, the `ProxySession` methods, and the `SandboxApp` methods are all **`suspend`** functions. Per-test bodies use `runTest { }`; JUnit5 `@BeforeAll`/`@AfterAll` (with `@TestInstance(Lifecycle.PER_CLASS)`) wrap their suspend calls in `runBlocking { }`.
     
    -### Test class docstring
    +### Proxy test class docstring
     
     Give every proxy integration test class this KDoc:
     
    diff --git a/uts/README.md b/uts/README.md
    index 615e1f936..c34c84ddb 100644
    --- a/uts/README.md
    +++ b/uts/README.md
    @@ -3,7 +3,7 @@
     > A practical, end-to-end explanation of the **Universal Test Specification (UTS)** and how it is
     > realised in the `ably-java` repository. Written for a developer who has never touched UTS before
     > and needs to understand *what it is*, *why it exists*, and *exactly how the Java/Kotlin code under
    -> `uts/` makes the unit and proxy-integration tests work*.
    +> `uts/` makes the unit, direct-sandbox, and proxy-integration tests work*.
     
     ---
     
    @@ -18,12 +18,13 @@
     7. [Proxy-Integration Infrastructure (real backend + fault injection)](#7-proxy-integration-infrastructure-real-backend--fault-injection)
     8. [Shared Async Helpers](#8-shared-async-helpers)
     9. [Walkthrough: the Unit Test (`ConnectionRecoveryTest`)](#9-walkthrough-the-unit-test-connectionrecoverytest)
    -10. [Walkthrough: the Proxy Test (`AuthReauthTest`)](#10-walkthrough-the-proxy-test-authreauthtest)
    -11. [Deviations: when the SDK disagrees with the spec](#11-deviations-when-the-sdk-disagrees-with-the-spec)
    -12. [How to Run the Tests](#12-how-to-run-the-tests)
    -13. [Quick Reference / Cheat-Sheet](#13-quick-reference--cheat-sheet)
    -14. [Appendix A: Request-Flow Diagrams](#14-appendix-a-request-flow-diagrams)
    -15. [Appendix B: Per-File API Reference](#15-appendix-b-per-file-api-reference)
    +10. [Walkthrough: the Direct-Sandbox Integration Test (`ChannelHistoryTest`)](#10-walkthrough-the-direct-sandbox-integration-test-channelhistorytest)
    +11. [Walkthrough: the Proxy Test (`AuthReauthTest`)](#11-walkthrough-the-proxy-test-authreauthtest)
    +12. [Deviations: when the SDK disagrees with the spec](#12-deviations-when-the-sdk-disagrees-with-the-spec)
    +13. [How to Run the Tests](#13-how-to-run-the-tests)
    +14. [Quick Reference / Cheat-Sheet](#14-quick-reference--cheat-sheet)
    +15. [Appendix A: Request-Flow Diagrams](#15-appendix-a-request-flow-diagrams)
    +16. [Appendix B: Per-File API Reference](#16-appendix-b-per-file-api-reference)
     
     ---
     
    @@ -75,18 +76,19 @@ structure, same assertions, same naming — don't optimise or skip steps. Every
     ## 2. The Three Test Tiers
     
     UTS divides tests into three tiers by *what infrastructure they need* and *what confidence they
    -give*. Understanding this split is the key to understanding the whole `uts/` module, because the two
    -tests you asked about sit in two different tiers.
    +give*. Understanding this split is the key to understanding the whole `uts/` module, because the
    +three example tests this guide walks through span all three tiers.
     
     | Tier | Transport | Backend | Purpose | Example in this repo |
     |------|-----------|---------|---------|----------------------|
     | **Unit** | **Mocked** (`MockWebSocket`, `MockHttpClient`) | none | Client-side logic: state machines, request formation, response parsing, timer behaviour. Fast & deterministic. | `unit/realtime/ConnectionRecoveryTest.kt` |
    -| **Direct sandbox integration** | Real network | Real Ably sandbox | Happy-path interop: connect, publish, subscribe. No fault injection. | `integration/standard//` *(tier exists; no tests yet)* |
    +| **Direct sandbox integration** | Real network | Real Ably sandbox | Happy-path interop: connect, publish, subscribe. No fault injection. | `integration/standard/realtime/ChannelHistoryTest.kt` |
     | **Proxy integration** | Real network **through a programmable proxy** | Real Ably sandbox | Fault behaviour: dropped connections, injected errors, timeouts, re-auth. | `integration/proxy/realtime/AuthReauthTest.kt` |
     
     Each tier folder is further organised **by module** (`realtime`, `liveobjects`, …): `unit//`,
     `integration/standard//`, and `integration/proxy//`. So a feature's tests sit together
    -by SDK area — the two example tests live at `unit/realtime/` and `integration/proxy/realtime/`.
    +by SDK area — the three example tests live at `unit/realtime/`, `integration/standard/realtime/`, and
    +`integration/proxy/realtime/`.
     
     Key principles (from [`integration-testing.md`](https://github.com/ably/specification/blob/main/uts/docs/integration-testing.md)):
     
    @@ -151,8 +153,10 @@ segregation exists because proxy tests have different infra needs, CI cadence, a
     ### 3.4 [`completion-status.md`](https://github.com/ably/specification/blob/main/uts/docs/completion-status.md) — the coverage matrix
     A big table mapping every features-spec group (`RSC`, `RTN`, `RTL`, `RTP`, …) to the UTS specs that
     cover it, with a per-tier summary (`unit:✓ proxy:✓`). This is the tracker for "what's done and
    -what's missing". The two tests you asked about correspond to these rows:
    +what's missing". The reference tests this guide walks through correspond to these rows:
     - `RTN16` (connection recovery) → unit spec `connection_recovery_test.md` → **`ConnectionRecoveryTest.kt`**.
    +- `RTL10d` (channel history) → direct-sandbox spec
    +  `realtime/integration/channel_history_test.md` → **`ChannelHistoryTest.kt`**.
     - `RTN22` / `RTC8a` (server-initiated re-auth) → proxy spec
       `realtime/integration/proxy/auth_reauth.md` → **`AuthReauthTest.kt`**.
     
    @@ -178,6 +182,7 @@ dependencies {
         testImplementation(project(":java"))                 // the SDK under test
         testImplementation(project(":network-client-core"))  // HttpEngine / WebSocketEngine interfaces
         testImplementation(kotlin("test"))
    +    testImplementation("org.junit.jupiter:junit-jupiter-params")  // @ParameterizedTest / @ValueSource (version from the JUnit BOM)
         testImplementation(libs.mockk)
         testImplementation(libs.coroutine.core)              // kotlinx.coroutines
         testImplementation(libs.coroutine.test)              // runTest, virtual time
    @@ -196,6 +201,10 @@ tasks.withType().configureEach {
     Takeaways:
     - Tests are **Kotlin + JUnit 5**, using **kotlinx.coroutines** for async control and **Ktor** as the
       HTTP client that talks to the sandbox REST API and the proxy control API.
    +- `junit-jupiter-params` adds **`@ParameterizedTest`** — used by integration specs to run their
    +  `json` / `msgpack` protocol variants as a single test parameterised on `useBinaryProtocol` (see §10.3).
    +  Its version is managed by the JUnit 5 BOM that `kotlin("test")` brings onto the test classpath, so no
    +  explicit version is pinned.
     - It depends on `:java` (the SDK) and `:network-client-core` (the pluggable transport interfaces the
       mocks implement).
     - The `--add-opens java.base/java.time` and `java.base/java.lang` flags grant reflective access into
    @@ -247,6 +256,7 @@ uts/src/test/kotlin/io/ably/lib/uts/
     └── integration/                         # ── INTEGRATION TESTS (real backend) ── · per module
         ├── standard/                        #   direct sandbox: happy-path, no fault injection
         │   ├── realtime/
    +    │   │   └── ChannelHistoryTest.kt    #   ← the DIRECT-SANDBOX test (RTL10d)
         │   └── liveobjects/
         └── proxy/                           #   sandbox through the fault-injecting uts-proxy
             ├── realtime/
    @@ -258,7 +268,7 @@ The mental model: **`infra/unit/` powers the unit tests, `infra/integration/` po
     kinds (`standard` + `proxy`), and `infra/Utils.kt` serves all of them.** Every tier is sub-divided **by
     module** (`realtime`, `liveobjects`, …) so a feature's tests sit together regardless of SDK area. The
     top-level `unit/` ↔ `infra/unit/` and `integration/` ↔ `infra/integration/` pairing is what the
    -`runUtsUnitTests` / `runUtsIntegrationTests` Gradle tasks key off (§12) — `runUtsIntegrationTests`
    +`runUtsUnitTests` / `runUtsIntegrationTests` Gradle tasks key off (§13) — `runUtsIntegrationTests`
     covers **both** `integration/standard/` and `integration/proxy/`.
     
     ---
    @@ -481,11 +491,17 @@ independent of the fault rules):
       auto-expire).
     - The Ktor client retries only **idempotent GETs** (never re-POSTs `/apps`, to avoid duplicate
       apps).
    +- Owns the single sandbox **host** constant `SandboxApp.sandboxHost`
    +  (`sandbox.realtime.ably-nonprod.net`) — the `nonprod:sandbox` endpoint used uniformly across the
    +  realtime/objects/rest integration specs, resolved to a hostname. It's the single source of truth for
    +  the upstream host: `ProxySession` defaults both its `realtimeHost` and `restHost` target to it, and
    +  direct-sandbox clients set `realtimeHost` / `restHost` from it (sandbox realtime and REST are the
    +  same host).
     
     `SandboxApp` is the shared backbone of *both* integration kinds: **proxy** tests pair it with a
     `ProxySession`, while **direct sandbox** tests (`integration/standard//`) use it alone —
    -connecting straight to `ProxyManager.sandboxRealtimeHost` / `sandboxRestHost` with no proxy and no
    -fault rules, for happy-path interop.
    +connecting straight to `SandboxApp.sandboxHost` with no proxy and no fault rules, for happy-path
    +interop.
     
     ---
     
    @@ -534,7 +550,7 @@ in each inactive one:
     - **INITIALIZED** (before connect) → null.
     - **CONNECTED** → non-null (sanity).
     - **CLOSING / CLOSED** → null (close nulls the key immediately).
    -- **FAILED** → null. *(Contains a documented **deviation** — see §11: the spec's fatal error
    +- **FAILED** → null. *(Contains a documented **deviation** — see §12: the spec's fatal error
       code 50000/500 isn't treated as fatal by the SDK, and `send_to_client_and_close` races the FAILED
       transition; the test uses code 40000/400 and plain `sendToClient`.)*
     - **SUSPENDED** → null. Built with a `FakeClock`: connect succeeds, then `simulateDisconnect()`,
    @@ -553,7 +569,7 @@ attempt, then `simulateDisconnect()` and reconnect. Asserts the **first** attemp
     ### 9.4 `RTN16f` — `recover` initialises `msgSerial` *(env-gated deviation)*
     Asserts the recovered `msgSerial` (42) is preserved. The SDK resets it to 0, so the spec-correct
     assertion `assertEquals(42L, …)` runs only under `RUN_DEVIATIONS`; otherwise a regression-guard
    -`assertEquals(0L, …)` runs. (See §11.)
    +`assertEquals(0L, …)` runs. (See §12.)
     
     ### 9.5 `RTN16f1` — malformed `recover` key degrades gracefully
     `recover = "this-is-not-valid-json!!!"`. Asserts the client still connects normally with a fresh
    @@ -572,7 +588,77 @@ client output, and the env-gated deviation pattern.
     
     ---
     
    -## 10. Walkthrough: the Proxy Test (`AuthReauthTest`)
    +## 10. Walkthrough: the Direct-Sandbox Integration Test (`ChannelHistoryTest`)
    +
    +**File:** `uts/.../uts/integration/standard/realtime/ChannelHistoryTest.kt` (package `io.ably.lib.uts.integration.standard.realtime`)
    +**Tier:** Direct-sandbox integration (real network, real Ably sandbox, **no** proxy, **no** fault injection).
    +**Spec point:** RTL10d — messages published by one realtime client are retrievable from a *separate*
    +client's `history()`.
    +
    +This is the reference for the **middle tier**. Like a proxy test it talks to the real backend, but it
    +connects *straight* to `SandboxApp.sandboxHost` — there is no `ProxyManager`, no `ProxySession`, and no
    +`connectThroughProxy` wiring. It's the shape every happy-path interop spec
    +(connect/publish/subscribe/presence) follows.
    +
    +### 10.1 Suite setup/teardown
    +Same `@TestInstance(PER_CLASS)` + `runBlocking` pattern as the proxy test, but provisioning **`SandboxApp`
    +only** — no `ProxyManager.ensureProxy()`:
    +```kotlin
    +@BeforeAll fun setUpAll()    = runBlocking { app = SandboxApp.create() }
    +@AfterAll  fun tearDownAll() = runBlocking { if (::app.isInitialized) app.delete() }
    +```
    +
    +### 10.2 The client — wired straight to the sandbox
    +A tiny `newClient` helper points the **real** transport at the sandbox host (no proxy in between). Setting
    +explicit hosts auto-disables fallback hosts (REC2c2), so there's nothing else to configure:
    +```kotlin
    +private fun newClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeClient {
    +    key = app.defaultKey
    +    realtimeHost = SandboxApp.sandboxHost   // sandbox.realtime.ably-nonprod.net
    +    restHost     = SandboxApp.sandboxHost
    +    this.useBinaryProtocol = useBinaryProtocol
    +    autoConnect  = false
    +}
    +```
    +(`TestRealtimeClient` is the same builder the unit tests use — it just isn't fed any mocks here, so it
    +drives the SDK's real network transport instead of a `MockWebSocket`.)
    +
    +### 10.3 Protocol variants — the `@ParameterizedTest` pattern
    +The spec declares a `PROTOCOL` dimension (`json` / `msgpack`) and says *each test runs once per variant*.
    +ably-java realises that with a JUnit 5 **parameterised test** over `useBinaryProtocol` — which is why the
    +module depends on `junit-jupiter-params` (§4.1):
    +```kotlin
    +@ParameterizedTest(name = "useBinaryProtocol={0}")
    +@ValueSource(booleans = [false, true])   // false = JSON, true = msgpack
    +fun `RTL10d - history contains messages published by another client`(useBinaryProtocol: Boolean) = runTest {
    +    …
    +}
    +```
    +A plain `@Test` test (no protocol dimension) stays a `@Test` — reach for `@ParameterizedTest` only when the
    +spec actually declares variants.
    +
    +### 10.4 The scenario — real publish, real history
    +Two independent clients on the same app: the publisher's *confirmed* messages must appear in the
    +subscriber's history. The integration-specific techniques on show:
    +- **Awaiting a publish ack.** Realtime publish is fire-and-forget, so to honour the spec's `AWAIT publish`
    +  the test wraps the (non-deprecated) `publish(name, data, Callback)` overload in a
    +  `suspendCancellableCoroutine` (`awaitPublish`), resuming on `onSuccess` and failing on `onError`. This is
    +  the integration analogue of the unit test's `awaitNextMessageFromClient()`.
    +- **`AWAIT attach()`** → `attach()` then `awaitChannelState(channel, ChannelState.attached)`.
    +- **Polling real REST state.** `history()` is a blocking REST call against the sandbox and the message
    +  store is eventually-consistent, so the test
    +  `pollUntil(10.seconds, 500.milliseconds) { subChannel.history(null).items().size == 3 }` — never a fixed
    +  sleep (the same anti-flake rule as the other tiers).
    +- **Order assertion.** History defaults to newest-first, so `items[0]` is `event3` … `items[2]` is `event1`.
    +
    +**What this test teaches about the infra:** `SandboxApp`-only provisioning, the direct-sandbox client
    +wiring (`realtimeHost`/`restHost` from `SandboxApp.sandboxHost`, no proxy), the protocol-variant
    +`@ParameterizedTest`, awaiting a publish ack via `Callback`, and `pollUntil` over a real
    +REST `history()` call.
    +
    +---
    +
    +## 11. Walkthrough: the Proxy Test (`AuthReauthTest`)
     
     **File:** `uts/.../uts/integration/proxy/realtime/AuthReauthTest.kt` (package `io.ably.lib.uts.integration.proxy.realtime`)
     **Tier:** Proxy integration (real sandbox + uts-proxy).
    @@ -580,7 +666,7 @@ client output, and the env-gated deviation pattern.
     frame with renewed auth details). Unit-test counterparts: `server_initiated_reauth_test.md`,
     `realtime_authorize.md`.
     
    -### 10.1 Suite setup/teardown
    +### 11.1 Suite setup/teardown
     ```kotlin
     @TestInstance(TestInstance.Lifecycle.PER_CLASS)   // one instance, so @BeforeAll can be non-static
     class AuthReauthTest {
    @@ -592,7 +678,7 @@ class AuthReauthTest {
     }
     ```
     
    -### 10.2 The test, step by step
    +### 11.2 The test, step by step
     1. **Create a session with no rules** — the fault will be injected *imperatively* later (late
        injection — the connect handshake runs against the real server unmodified):
        ```kotlin
    @@ -646,7 +732,7 @@ filter by `type`/`direction`/`message.action`).
     
     ---
     
    -## 11. Deviations: when the SDK disagrees with the spec
    +## 12. Deviations: when the SDK disagrees with the spec
     
     `uts/.../io/ably/lib/uts/deviations.md` is the single catalogue of every place the ably-java SDK behaves
     differently from the features spec, discovered during translation. Each entry records: the **spec
    @@ -658,13 +744,13 @@ gates it behind the `RUN_DEVIATIONS` env var, with a regression-guard assertion
     behaviour running by default. Normal runs stay green; `RUN_DEVIATIONS=1` turns the failing assertions
     on so the gap is reproducible and the test flips automatically once the SDK is fixed.
     
    -Current entries relevant to the two tests:
    +Current entries relevant to the walkthrough tests:
     
     | Spec point | Gist | Touches |
     |------------|------|---------|
     | **RTN16f** | SDK resets `msgSerial` to 0 on connect even with `recover`; spec says preserve it (42). | `ConnectionRecoveryTest` (§9.4) — `assertEquals(42L,…)` gated, `assertEquals(0L,…)` default guard. |
     | **RTN16g2** | Spec's fatal error 50000/500 isn't fatal to the SDK (`isFatalError()` needs code 40000–49999 or status < 500); also `send_to_client_and_close` races the FAILED transition. | `ConnectionRecoveryTest` (§9.2) — uses 40000/400 + plain `sendToClient`. |
    -| **RTL13b** | `ATTACHING → SUSPENDED` via `realtimeRequestTimeout` not implemented for channel attach. | various channel tests (not the two here). |
    +| **RTL13b** | `ATTACHING → SUSPENDED` via `realtimeRequestTimeout` not implemented for channel attach. | various channel tests (not the walkthroughs here). |
     | **RTL13c** | `channelRetryTimeout` not cancelled when the connection leaves CONNECTED. | various channel tests; assertions gated behind `RUN_DEVIATIONS`. |
     
     > These deviations are **valuable output**, not failures — each one is a precise, reproducible bug
    @@ -672,7 +758,7 @@ Current entries relevant to the two tests:
     
     ---
     
    -## 12. How to Run the Tests
    +## 13. How to Run the Tests
     
     There are two custom Gradle tasks (registered in `uts/build.gradle.kts`), filtered by package — they
     mirror `runLiveObjectsUnitTests` / `runLiveObjectsIntegrationTests` in the `liveobjects` module:
    @@ -716,9 +802,9 @@ Notes:
     
     ---
     
    -## 13. Quick Reference / Cheat-Sheet
    +## 14. Quick Reference / Cheat-Sheet
     
    -**The two seams that make unit tests possible** (`DebugOptions`):
    +**The three seams that make unit tests possible** (`DebugOptions`):
     `webSocketEngineFactory` (WS), `httpEngine` (HTTP), `clock` (time).
     
     **Build a unit-test client:**
    @@ -754,7 +840,7 @@ and record in `deviations.md`.
     
     ---
     
    -## 14. Appendix A: Request-Flow Diagrams
    +## 15. Appendix A: Request-Flow Diagrams
     
     ### A.1 Unit test — mocked WebSocket (no network)
     
    @@ -829,7 +915,7 @@ sees the control plane; the test never speaks the data plane directly.
     
     ---
     
    -## 15. Appendix B: Per-File API Reference
    +## 16. Appendix B: Per-File API Reference
     
     A one-stop table of every Kotlin source file under `uts/src/test/` and the SDK seams they use, so
     nothing is left implicit.
    @@ -855,9 +941,9 @@ nothing is left implicit.
     
     | File | Key public surface | Role |
     |------|--------------------|------|
    -| `proxy/ProxyManager.kt` | `object ProxyManager`: `ensureProxy(timeoutMs)`, `stopProxy()`, `CONTROL_PORT=10100`, `sandboxRealtimeHost`, `sandboxRestHost`; pinned `PROXY_VERSION=v0.3.0` + per-arch checksums; `uts.proxy.localPath` override | Downloads/verifies/launches the `uts-proxy` binary; one shared process per run. *(package `…integration.proxy`)* |
    +| `proxy/ProxyManager.kt` | `object ProxyManager`: `ensureProxy(timeoutMs)`, `stopProxy()`, `CONTROL_PORT=10100`; pinned `PROXY_VERSION=v0.3.0` + per-arch checksums; `uts.proxy.localPath` override | Downloads/verifies/launches the `uts-proxy` binary; one shared process per run. *(package `…integration.proxy`)* |
     | `proxy/ProxySession.kt` | `class ProxySession` (`create(rules,port,timeoutMs,realtimeHost,restHost)`, `addRules`, `triggerAction`, `getLog(): List`, `close`, `sessionId`, `proxyPort`, `proxyHost`); `data class Event`; `typealias ProxyRule`; rule builders `wsConnectRule`/`wsFrameToClientRule`/`wsFrameToServerRule`/`httpRequestRule`; `ClientOptionsBuilder.connectThroughProxy(session)` | Typed client for the proxy control REST API + client wiring. *(package `…integration.proxy`)* |
    -| `SandboxApp.kt` | `class SandboxApp` (`create()`, `delete()`, `appId`, `defaultKey`, `keys`) | Provisions/tears down a throwaway sandbox app from `ably-common`'s `test-app-setup.json`. *(package `…integration`)* |
    +| `SandboxApp.kt` | `class SandboxApp` (`create()`, `delete()`, `appId`, `defaultKey`, `keys`); `SandboxApp.sandboxHost` (`sandbox.realtime.ably-nonprod.net`) | Provisions/tears down a throwaway sandbox app from `ably-common`'s `test-app-setup.json`; owns the single upstream sandbox host constant. *(package `…integration`)* |
     
     ### B.3 Shared helpers & tests
     
    @@ -865,12 +951,14 @@ nothing is left implicit.
     |------|--------------------|------|
     | `infra/Utils.kt` | `awaitState(client,target,timeout=5s)`, `awaitChannelState(channel,target,timeout=5s)`, `pollUntil(timeout=15s,interval=100ms){ }` | Shared wall-clock coroutine waits (package `io.ably.lib.uts.infra`); listener registered before state check. |
     | `unit/realtime/ConnectionRecoveryTest.kt` | 6 `@Test`s: RTN16g/g1, RTN16g2, RTN16k, RTN16f, RTN16f1, RTN16j | Unit tier (`io.ably.lib.uts.unit.realtime`) — connection recovery (mocked WS, FakeClock, env-gated deviations). |
    +| `integration/standard/realtime/ChannelHistoryTest.kt` | 1 `@ParameterizedTest` (RTL10d) × {JSON, msgpack} | Direct-sandbox tier (`io.ably.lib.uts.integration.standard.realtime`) — cross-client history durability (`SandboxApp`, no proxy; awaited publish + `pollUntil` on `history()`). |
     | `integration/proxy/realtime/AuthReauthTest.kt` | 1 `@Test` (two `@UTS`: RTN22, RTC8a) | Integration tier (`io.ably.lib.uts.integration.proxy.realtime`) — server-initiated re-authentication. |
     | `deviations.md` | RTN16f, RTN16g2, RTL13b, RTL13c | Catalogue of SDK-vs-spec divergences. |
     
    -> **Coverage note:** at the time of writing, the `uts/` module contains exactly **two test classes**
    -> (**7** `@Test` methods total: 6 in `ConnectionRecoveryTest` + 1 in `AuthReauthTest`). The infrastructure under
    -> `infra/unit/` and `infra/integration/` is built out far beyond what these two tests exercise (full HTTP
    +> **Coverage note:** this guide walks through **one reference test per tier** — `ConnectionRecoveryTest`
    +> (unit, §9), `ChannelHistoryTest` (direct-sandbox, §10), and `AuthReauthTest` (proxy, §11). The `uts/`
    +> module additionally carries LiveObjects tests across the same tiers, and the infrastructure under
    +> `infra/unit/` and `infra/integration/` is built out beyond what any single test exercises (full HTTP
     > mock, all four rule builders, REST proxy wiring, etc.), anticipating the broader UTS coverage
     > catalogued in [`completion-status.md`](https://github.com/ably/specification/blob/main/uts/docs/completion-status.md).
     
    @@ -890,5 +978,5 @@ nothing is left implicit.
     | Unit mocks | `uts/.../uts/infra/unit/*` |
     | Integration helpers | `uts/.../uts/infra/integration/*` (+ `…/integration/proxy/*`) |
     | Async helpers | `uts/.../uts/infra/Utils.kt` (awaits), `…/uts/infra/unit/Utils.kt` (ConnectionDetails builder) |
    -| The two example tests | `…/uts/unit/realtime/ConnectionRecoveryTest.kt`, `…/uts/integration/proxy/realtime/AuthReauthTest.kt` |
    +| The three example tests | `…/uts/unit/realtime/ConnectionRecoveryTest.kt`, `…/uts/integration/standard/realtime/ChannelHistoryTest.kt`, `…/uts/integration/proxy/realtime/AuthReauthTest.kt` |
     | Deviations | `uts/.../io/ably/lib/uts/deviations.md` |
    diff --git a/uts/build.gradle.kts b/uts/build.gradle.kts
    index 5286e59eb..4eca7f9c4 100644
    --- a/uts/build.gradle.kts
    +++ b/uts/build.gradle.kts
    @@ -11,6 +11,8 @@ dependencies {
         // helpers reach the internal wire/message classes (e.g. for build_public_object_message) by reflection.
         testRuntimeOnly(project(":liveobjects"))
         testImplementation(kotlin("test"))
    +    // @ParameterizedTest / @ValueSource — version managed by the junit-bom on the test classpath.
    +    testImplementation("org.junit.jupiter:junit-jupiter-params")
         testImplementation(libs.mockk)
         testImplementation(libs.coroutine.core)
         testImplementation(libs.coroutine.test)
    diff --git a/uts/index.html b/uts/index.html
    index 001a2718a..70dcca0f2 100644
    --- a/uts/index.html
    +++ b/uts/index.html
    @@ -121,13 +121,14 @@ 

    Guide

    7 · Proxy Infrastructure 8 · Shared Async Helpers 9 · Walkthrough: Unit Test - 10 · Walkthrough: Proxy Test - 11 · Deviations - 12 · How to Run - 13 · Cheat-Sheet + 10 · Walkthrough: Direct-Sandbox Test + 11 · Walkthrough: Proxy Test + 12 · Deviations + 13 · How to Run + 14 · Cheat-Sheet

    Appendices

    - A · Flow Diagrams - B · Per-File Reference + A · Flow Diagrams + B · Per-File Reference Source Map @@ -136,7 +137,7 @@

    Appendices

    Ably SDK Testing

    UTS in ably-java
    A Human-Readable Guide

    -

    A practical, end-to-end explanation of the Universal Test Specification (UTS) and exactly how the Java/Kotlin code under uts/ makes the unit and proxy-integration tests work — written for a developer who has never touched UTS before.

    +

    A practical, end-to-end explanation of the Universal Test Specification (UTS) and exactly how the Java/Kotlin code under uts/ makes the unit, direct-sandbox, and proxy-integration tests work — written for a developer who has never touched UTS before.

    @@ -188,13 +189,13 @@

    1 Introduction: What is UTS?

    2 The Three Test Tiers

    -

    UTS divides tests into three tiers by what infrastructure they need and what confidence they give. This split is the key to understanding the whole uts/ module — the two example tests sit in two different tiers.

    +

    UTS divides tests into three tiers by what infrastructure they need and what confidence they give. This split is the key to understanding the whole uts/ module — the three example tests span all three tiers.

    Unit mocked

    Transport: mocked (MockWebSocket, MockHttpClient). Backend: none.
    Client-side logic: state machines, request formation, response parsing, timers. Fast & deterministic.
    → unit/realtime/ConnectionRecoveryTest.kt

    -

    Direct sandbox real net

    Transport: real. Backend: real Ably sandbox.
    Happy-path interop: connect, publish, subscribe. No fault injection.
    → integration/standard/<module>/ (tier exists; no tests yet)

    +

    Direct sandbox real net

    Transport: real. Backend: real Ably sandbox.
    Happy-path interop: connect, publish, subscribe. No fault injection.
    → integration/standard/realtime/ChannelHistoryTest.kt

    Proxy integration faults

    Transport: real, through a programmable proxy. Backend: real sandbox.
    Fault behaviour: dropped connections, injected errors, timeouts, re-auth.
    → integration/proxy/realtime/AuthReauthTest.kt

    -

    Each tier folder is further organised by module (realtime, liveobjects, …): unit/<module>/, integration/standard/<module>/, and integration/proxy/<module>/. So a feature's tests sit together by SDK area — the two example tests live at unit/realtime/ and integration/proxy/realtime/.

    +

    Each tier folder is further organised by module (realtime, liveobjects, …): unit/<module>/, integration/standard/<module>/, and integration/proxy/<module>/. So a feature's tests sit together by SDK area — the three example tests live at unit/realtime/, integration/standard/realtime/, and integration/proxy/realtime/.

    Key principles (from integration-testing.md):

    • Integration tests do not replace unit tests. A spec point covered by a proxy test should also have a unit test. The unit test proves client logic; the proxy test proves client & real server agree.
    • @@ -246,9 +247,10 @@

      3.3 completion-status.md — the coverage matrix

      -

      A big table mapping every features-spec group (RSC, RTN, RTL, RTP…) to the UTS specs that cover it, with a per-tier summary (unit:✓ proxy:✓). The two example tests correspond to these rows:

      +

      A big table mapping every features-spec group (RSC, RTN, RTL, RTP…) to the UTS specs that cover it, with a per-tier summary (unit:✓ proxy:✓). The reference tests this guide walks through correspond to these rows:

      • RTN16 (connection recovery) → unit spec connection_recovery_test.mdConnectionRecoveryTest.kt.
      • +
      • RTL10d (channel history) → direct-sandbox spec realtime/integration/channel_history_test.mdChannelHistoryTest.kt.
      • RTN22 / RTC8a (server-initiated re-auth) → proxy spec realtime/integration/proxy/auth_reauth.mdAuthReauthTest.kt.
      Fifth, referenced doc @@ -267,6 +269,7 @@

      4.1 uts/build.gradle.kts

      testImplementation(project(":java")) // the SDK under test testImplementation(project(":network-client-core")) // HttpEngine / WebSocketEngine interfaces testImplementation(kotlin("test")) + testImplementation("org.junit.jupiter:junit-jupiter-params") // @ParameterizedTest / @ValueSource (version from the JUnit BOM) testImplementation(libs.mockk) testImplementation(libs.coroutine.core) // kotlinx.coroutines testImplementation(libs.coroutine.test) // runTest, virtual time @@ -325,12 +328,13 @@

      4.2 Directory layout

      └── integration/ # ── INTEGRATION TESTS (real backend) ── · per module ├── standard/ # direct sandbox: happy-path, no fault injection │ ├── realtime/ + │ │ └── ChannelHistoryTest.kt # ← the DIRECT-SANDBOX test (RTL10d) │ └── liveobjects/ └── proxy/ # sandbox through the fault-injecting uts-proxy ├── realtime/ │ └── AuthReauthTest.kt # ← the PROXY test (RTN22, RTC8a) └── liveobjects/
    -
    Mental model

    infra/unit/ powers the unit tests · infra/integration/ powers both integration kinds (standard + proxy) · infra/Utils.kt serves all of them. Every tier is sub-divided by module (realtime, liveobjects, …). The top-level unit/infra/unit/ and integration/infra/integration/ pairing is what the runUtsUnitTests / runUtsIntegrationTests Gradle tasks key off (§12) — runUtsIntegrationTests covers both integration/standard/ and integration/proxy/.

    +
    Mental model

    infra/unit/ powers the unit tests · infra/integration/ powers both integration kinds (standard + proxy) · infra/Utils.kt serves all of them. Every tier is sub-divided by module (realtime, liveobjects, …). The top-level unit/infra/unit/ and integration/infra/integration/ pairing is what the runUtsUnitTests / runUtsIntegrationTests Gradle tasks key off (§13) — runUtsIntegrationTests covers both integration/standard/ and integration/proxy/.

    @@ -469,7 +473,7 @@

    7.3 SandboxApp — a throwaway app on the real sandbox

  • SandboxApp.create() fetches the canonical test-app-setup.json from ably-common (specifically its post_apps sub-object), POSTs it to https://sandbox.realtime.ably-nonprod.net/apps, and exposes appId, defaultKey (full-capability appId.keyId:keySecret, from the keyStr field), and the full keys list.
  • delete() removes the app in teardown (best-effort — errors swallowed, sandbox apps auto-expire).
  • The Ktor client retries only idempotent GETs (never re-POSTs /apps, to avoid duplicate apps).
  • -
  • SandboxApp is the shared backbone of both integration kinds: proxy tests pair it with a ProxySession, while direct sandbox tests (integration/standard/<module>/) use it alone — connecting straight to ProxyManager.sandboxRealtimeHost / sandboxRestHost with no proxy and no fault rules, for happy-path interop.
  • +
  • SandboxApp is the shared backbone of both integration kinds: proxy tests pair it with a ProxySession, while direct sandbox tests (integration/standard/<module>/) use it alone — connecting straight to SandboxApp.sandboxHost with no proxy and no fault rules, for happy-path interop.
  • The app is provisioned directly (not through the proxy), so it's independent of the fault rules under test.

    @@ -501,7 +505,7 @@

    9.2 RTN16g2createRecoveryKey() returns null i
  • INITIALIZED (before connect) → null.
  • CONNECTED → non-null (sanity).
  • CLOSING / CLOSED → null (close nulls the key immediately).
  • -
  • FAILED → null. (Contains a documented deviation — see §11: the spec's fatal error code 50000/500 isn't treated as fatal by the SDK, and send_to_client_and_close races the FAILED transition; the test uses code 40000/400 and plain sendToClient.)
  • +
  • FAILED → null. (Contains a documented deviation — see §12: the spec's fatal error code 50000/500 isn't treated as fatal by the SDK, and send_to_client_and_close races the FAILED transition; the test uses code 40000/400 and plain sendToClient.)
  • SUSPENDED → null. Built with a FakeClock: connect succeeds, then simulateDisconnect(), then a coroutine refuses every reconnection attempt while fakeClock.advance(2.seconds) loops until the short connectionStateTtl (800 ms) expires and the client gives up to SUSPENDED.
  • Technique: the textbook await-style example — the first connection succeeds via awaitConnectionAttempt(), but reconnections need the refused response, so a separate refuseJob coroutine drives them; combined with fake timers this gives a deterministic SUSPENDED.

    @@ -510,7 +514,7 @@

    9.3 RTN16krecover adds the recoverConstructs the client with recover = <recoveryKey>, captures conn.queryParams on each attempt, then simulateDisconnect() + reconnect. Asserts the first attempt carries recover=<key> (no resume), the second carries resume=<new key> (no recover) — recover is a one-shot bootstrap; subsequent reconnections use resume.

    9.4 RTN16frecover initialises msgSerial env-gated deviation

    -

    Asserts the recovered msgSerial (42) is preserved. The SDK resets it to 0, so the spec-correct assertion assertEquals(42L, …) runs only under RUN_DEVIATIONS; otherwise a regression-guard assertEquals(0L, …) runs. (See §11.)

    +

    Asserts the recovered msgSerial (42) is preserved. The SDK resets it to 0, so the spec-correct assertion assertEquals(42L, …) runs only under RUN_DEVIATIONS; otherwise a regression-guard assertEquals(0L, …) runs. (See §12.)

    9.5 RTN16f1 — malformed recover key degrades gracefully

    recover = "this-is-not-valid-json!!!". Asserts the client still connects normally with a fresh identity, no recover/resume params, and exactly one connection attempt — a bad key is logged and ignored, not fatal.

    @@ -522,10 +526,52 @@

    9.6 RTN16jrecover instantiates channels with
    -

    10 Walkthrough: the Proxy Test (AuthReauthTest)

    -

    Tier: Proxy integration (real sandbox + uts-proxy). Spec points: RTN22 (server-initiated re-authentication) and RTC8a (client sends an AUTH frame with renewed auth details). Unit counterparts: server_initiated_reauth_test.md, realtime_authorize.md.

    +

    10 Walkthrough: the Direct-Sandbox Integration Test (ChannelHistoryTest)

    +

    File: uts/.../integration/standard/realtime/ChannelHistoryTest.kt · Tier: Direct-sandbox integration (real network, real Ably sandbox, no proxy, no fault injection). Spec point: RTL10d — messages published by one realtime client are retrievable from a separate client's history().

    +

    The reference for the middle tier. Like a proxy test it talks to the real backend, but it connects straight to SandboxApp.sandboxHost — no ProxyManager, no ProxySession, no connectThroughProxy. It's the shape every happy-path interop spec (connect/publish/subscribe/presence) follows.

    10.1 Suite setup/teardown

    +

    Same @TestInstance(PER_CLASS) + runBlocking pattern as the proxy test, but provisioning SandboxApp only — no ProxyManager.ensureProxy():

    +
    @BeforeAll fun setUpAll()    = runBlocking { app = SandboxApp.create() }
    +@AfterAll  fun tearDownAll() = runBlocking { if (::app.isInitialized) app.delete() }
    + +

    10.2 The client — wired straight to the sandbox

    +

    A tiny newClient helper points the real transport at the sandbox host (no proxy in between). Setting explicit hosts auto-disables fallback hosts (REC2c2):

    +
    private fun newClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeClient {
    +    key = app.defaultKey
    +    realtimeHost = SandboxApp.sandboxHost   // sandbox.realtime.ably-nonprod.net
    +    restHost     = SandboxApp.sandboxHost
    +    this.useBinaryProtocol = useBinaryProtocol
    +    autoConnect  = false
    +}
    +

    TestRealtimeClient is the same builder the unit tests use — it just isn't fed any mocks here, so it drives the SDK's real network transport instead of a MockWebSocket.

    + +

    10.3 Protocol variants — the @ParameterizedTest pattern

    +

    The spec declares a PROTOCOL dimension (json / msgpack) and says each test runs once per variant. ably-java realises that with a JUnit 5 parameterised test over useBinaryProtocol — which is why the module depends on junit-jupiter-params (§4):

    +
    @ParameterizedTest(name = "useBinaryProtocol={0}")
    +@ValueSource(booleans = [false, true])   // false = JSON, true = msgpack
    +fun `RTL10d - history contains messages published by another client`(useBinaryProtocol: Boolean) = runTest {
    +    …
    +}
    +

    A plain @Test test (no protocol dimension) stays a @Test — reach for @ParameterizedTest only when the spec actually declares variants.

    + +

    10.4 The scenario — real publish, real history

    +

    Two independent clients on the same app: the publisher's confirmed messages must appear in the subscriber's history. The integration-specific techniques on show:

    +
      +
    • Awaiting a publish ack. Realtime publish is fire-and-forget, so to honour the spec's AWAIT publish the test wraps the (non-deprecated) publish(name, data, Callback<PublishResult>) overload in suspendCancellableCoroutine (awaitPublish), resuming on onSuccess and failing on onError — the integration analogue of the unit test's awaitNextMessageFromClient().
    • +
    • AWAIT attach()attach() then awaitChannelState(channel, ChannelState.attached).
    • +
    • Polling real REST state. history() is a blocking REST call and the message store is eventually-consistent, so pollUntil(10.seconds, 500.milliseconds) { subChannel.history(null).items().size == 3 } — never a fixed sleep.
    • +
    • Order assertion. History defaults to newest-first, so items[0] is event3items[2] is event1.
    • +
    +
    What this teaches

    SandboxApp-only provisioning, the direct-sandbox client wiring (realtimeHost/restHost from SandboxApp.sandboxHost, no proxy), the protocol-variant @ParameterizedTest, awaiting a publish ack via Callback<PublishResult>, and pollUntil over a real REST history() call.

    +
    + + +
    +

    11 Walkthrough: the Proxy Test (AuthReauthTest)

    +

    Tier: Proxy integration (real sandbox + uts-proxy). Spec points: RTN22 (server-initiated re-authentication) and RTC8a (client sends an AUTH frame with renewed auth details). Unit counterparts: server_initiated_reauth_test.md, realtime_authorize.md.

    + +

    11.1 Suite setup/teardown

    @TestInstance(TestInstance.Lifecycle.PER_CLASS)   // one instance, so @BeforeAll can be non-static
     class AuthReauthTest {
         @BeforeAll fun setUpAll() = runBlocking {
    @@ -535,7 +581,7 @@ 

    10.1 Suite setup/teardown

    @AfterAll fun tearDownAll() = runBlocking { if (::app.isInitialized) app.delete() } }
    -

    10.2 The test, step by step

    +

    11.2 The test, step by step

    1. Create a session with no rules — the fault is injected imperatively later (late injection — the handshake runs against the real server unmodified): val session = ProxySession.create(rules = emptyList())
    2. Auth via authCallback — a locally-signed TokenRequest from the same sandbox key (no external JWT library). A counter records how many times the callback runs: @@ -570,24 +616,24 @@

      10.2 The test, step by step

      What this teaches

      ProxyManager.ensureProxy + SandboxApp setup, connectThroughProxy, late imperative fault injection via triggerAction, real-network waiting with pollUntil, and proxy-log assertions as the primary verification.

    - -
    -

    11 Deviations: when the SDK disagrees with the spec

    + +
    +

    12 Deviations: when the SDK disagrees with the spec

    uts/.../deviations.md is the single catalogue of every place the ably-java SDK behaves differently from the features spec, discovered during translation. Each entry records: the spec point, what the spec requires, what the SDK does, the root cause (file/function, where known), the workaround in tests, and the affected tests.

    The mechanism: the test keeps the spec-correct assertion but gates it behind the RUN_DEVIATIONS env var, with a regression-guard assertion for the SDK's actual behaviour running by default. Normal runs stay green; RUN_DEVIATIONS=1 turns the failing assertions on, and the test flips automatically once the SDK is fixed.

    HelperSignaturePurpose
    awaitState(client, target, timeout=5s)suspend until connection.state == target (or already there)
    - +
    Spec pointGistTouches
    RTN16fSDK resets msgSerial to 0 on connect even with recover; spec says preserve it (42).ConnectionRecoveryTest (§9.4) — assertEquals(42L,…) gated, assertEquals(0L,…) default guard.
    RTN16g2Spec's fatal error 50000/500 isn't fatal to the SDK (isFatalError() needs code 40000–49999 or status < 500); also send_to_client_and_close races the FAILED transition.ConnectionRecoveryTest (§9.2) — uses 40000/400 + plain sendToClient.
    RTL13bATTACHING → SUSPENDED via realtimeRequestTimeout not implemented for channel attach.various channel tests (not the two here).
    RTL13bATTACHING → SUSPENDED via realtimeRequestTimeout not implemented for channel attach.various channel tests (not the walkthroughs here).
    RTL13cchannelRetryTimeout not cancelled when the connection leaves CONNECTED.various channel tests; assertions gated behind RUN_DEVIATIONS.
    Why this matters

    These deviations are valuable output, not failures — each one is a precise, reproducible bug report the SDK team can act on, and the gated test becomes the acceptance test for the fix.

    - -
    -

    12 How to Run the Tests

    + +
    +

    13 How to Run the Tests

    Two custom Gradle tasks (registered in uts/build.gradle.kts), filtered by package — they mirror runLiveObjectsUnitTests / runLiveObjectsIntegrationTests in the liveobjects module:

    # Unit tests only — io.ably.lib.uts.unit.*  (fast, no network). This is the PR gate.
     ./gradlew :uts:runUtsUnitTests
    @@ -618,9 +664,9 @@ 

    12 How to Run the Tests

    - -
    -

    13 Quick Reference / Cheat-Sheet

    + +
    +

    14 Quick Reference / Cheat-Sheet

    The three seams that make unit tests possible (DebugOptions): webSocketEngineFactory httpEngine clock

    Build a unit-test client

    val mock = MockWebSocket { onConnectionAttempt = { it.respondWithSuccess(CONNECTED_MESSAGE) } }
    @@ -638,8 +684,8 @@ 

    Build a proxy-test client

    Decision tree on failure: spec wrong → fix test + record UTS spec error · translation wrong → fix test · SDK non-compliant → gate spec-correct assertion behind RUN_DEVIATIONS + record in deviations.md.

    - -
    + +

    A Appendix A: Request-Flow Diagrams

    A.1 Unit test — mocked WebSocket (no network)

    @@ -730,8 +776,8 @@

    A.2 Proxy integration test — real backend through the fault-injecting prox

    - -
    + +

    B Appendix B: Per-File API Reference

    B.1 Unit-test infrastructure — io.ably.lib.uts.infra.unit

    @@ -754,9 +800,9 @@

    B.1 Unit-test infrastructure — io.ably.lib.uts.infra.unit

    B.2 Integration infrastructure — io.ably.lib.uts.infra.integration (and …integration.proxy) - + - +
    FileKey public surfaceRole
    proxy/ProxyManager.ktobject ProxyManager: ensureProxy(timeoutMs), stopProxy(), CONTROL_PORT=10100, sandboxRealtimeHost, sandboxRestHost; pinned PROXY_VERSION=v0.3.0 + per-arch checksums; uts.proxy.localPath overrideDownloads/verifies/launches the uts-proxy binary; one shared process per run. (package …integration.proxy)
    proxy/ProxyManager.ktobject ProxyManager: ensureProxy(timeoutMs), stopProxy(), CONTROL_PORT=10100; pinned PROXY_VERSION=v0.3.0 + per-arch checksums; uts.proxy.localPath overrideDownloads/verifies/launches the uts-proxy binary; one shared process per run. (package …integration.proxy)
    proxy/ProxySession.ktclass ProxySession (create(rules,port,timeoutMs,realtimeHost,restHost), addRules, triggerAction, getLog(): List<Event>, close, sessionId, proxyPort, proxyHost); data class Event; typealias ProxyRule; rule builders wsConnectRule/wsFrameToClientRule/wsFrameToServerRule/httpRequestRule; ClientOptionsBuilder.connectThroughProxy(session)Typed client for the proxy control REST API + client wiring. (package …integration.proxy)
    SandboxApp.ktclass SandboxApp (create(), delete(), appId, defaultKey, keys)Provisions/tears down a throwaway sandbox app from ably-common's test-app-setup.json. (package …integration)
    SandboxApp.ktclass SandboxApp (create(), delete(), appId, defaultKey, keys); SandboxApp.sandboxHost (sandbox.realtime.ably-nonprod.net)Provisions/tears down a throwaway sandbox app from ably-common's test-app-setup.json; owns the single upstream sandbox host constant. (package …integration)

    B.3 Shared helpers & tests

    @@ -764,10 +810,11 @@

    B.3 Shared helpers & tests

    FileKey public surfaceRole infra/Utils.ktawaitState(client,target,timeout=5s), awaitChannelState(channel,target,timeout=5s), pollUntil(timeout=15s,interval=100ms){ }Shared wall-clock coroutine waits (package io.ably.lib.uts.infra); listener registered before state check. unit/realtime/ConnectionRecoveryTest.kt6 @Tests: RTN16g/g1, RTN16g2, RTN16k, RTN16f, RTN16f1, RTN16jUnit tier (io.ably.lib.uts.unit.realtime) — connection recovery (mocked WS, FakeClock, env-gated deviations). + integration/standard/realtime/ChannelHistoryTest.kt1 @ParameterizedTest (RTL10d) × {JSON, msgpack}Direct-sandbox tier (io.ably.lib.uts.integration.standard.realtime) — cross-client history durability (SandboxApp, no proxy; awaited publish + pollUntil on history()). integration/proxy/realtime/AuthReauthTest.kt1 @Test (two @UTS: RTN22, RTC8a)Integration tier (io.ably.lib.uts.integration.proxy.realtime) — server-initiated re-authentication. deviations.mdRTN16f, RTN16g2, RTL13b, RTL13cCatalogue of SDK-vs-spec divergences. -
    Coverage note

    At the time of writing, the uts/ module contains exactly two test classes (7 @Test methods total: 6 in ConnectionRecoveryTest + 1 in AuthReauthTest). The infrastructure under infra/unit/ and infra/integration/ is built out far beyond what these two tests exercise (full HTTP mock, all four rule builders, REST proxy wiring), anticipating the broader UTS coverage catalogued in completion-status.md.

    +
    Coverage note

    This guide walks through one reference test per tierConnectionRecoveryTest (unit, §9), ChannelHistoryTest (direct-sandbox, §10), and AuthReauthTest (proxy, §11). The uts/ module additionally carries LiveObjects tests across the same tiers, and the infrastructure under infra/unit/ and infra/integration/ is built out beyond what any single test exercises (full HTTP mock, all four rule builders, REST proxy wiring), anticipating the broader UTS coverage catalogued in completion-status.md.

    @@ -784,7 +831,7 @@

    Source map — where each fact comes from

    Unit mocksuts/.../uts/infra/unit/* Integration helpersuts/.../uts/infra/integration/* (+ …/integration/proxy/*) Async helpersuts/.../uts/infra/Utils.kt (awaits), …/uts/infra/unit/Utils.kt (ConnectionDetails builder) - The two example tests…/uts/unit/realtime/ConnectionRecoveryTest.kt, …/uts/integration/proxy/realtime/AuthReauthTest.kt + The three example tests…/uts/unit/realtime/ConnectionRecoveryTest.kt, …/uts/integration/standard/realtime/ChannelHistoryTest.kt, …/uts/integration/proxy/realtime/AuthReauthTest.kt Deviationsuts/.../io/ably/lib/uts/deviations.md

    Generated from README.md (in this uts/ directory). Single self-contained HTML file — no external assets.

    diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt index a697618a0..3035d05a4 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt @@ -2,7 +2,6 @@ package io.ably.lib.uts.infra.integration import com.google.gson.JsonElement import com.google.gson.JsonParser -import io.ably.lib.uts.infra.integration.proxy.ProxyManager import io.ktor.client.* import io.ktor.client.engine.cio.* import io.ktor.client.network.sockets.* @@ -67,7 +66,14 @@ class SandboxApp private constructor( ) { companion object { - private val sandboxBaseUrl = "https://${ProxyManager.sandboxRestHost}" + /** + * The Ably **nonprod sandbox** host — the `nonprod:sandbox` endpoint (used uniformly across the + * realtime/objects/rest integration specs), resolved to a hostname. Realtime and REST share this + * single host, so point both transports at it: set `realtimeHost` and/or `restHost` from here. + */ + const val sandboxHost = "sandbox.realtime.ably-nonprod.net" + + private const val sandboxBaseUrl = "https://$sandboxHost" /** The canonical app spec shared across all Ably SDK test suites. */ private const val APP_SETUP_URL = diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt index 099f404bf..ac344c760 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt @@ -57,13 +57,9 @@ object ProxyManager { private const val PROXY_VERSION = "v0.3.0" private const val VERSION_BARE = "0.3.0" const val CONTROL_PORT = 10100 - private const val SANDBOX_HOST = "sandbox.realtime.ably-nonprod.net" private const val GITHUB_BASE = "https://github.com/ably/uts-proxy/releases/download/$PROXY_VERSION" - val sandboxRealtimeHost: String = SANDBOX_HOST - val sandboxRestHost: String = SANDBOX_HOST - private val CHECKSUMS = mapOf( "uts-proxy_${VERSION_BARE}_darwin_amd64.tar.gz" to "1355526543c3022f87efb7f564f55200b78edc68d84c7dba2e49f63429e3b788", diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt index cee13b22b..6a1fe1f74 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt @@ -3,6 +3,7 @@ package io.ably.lib.uts.infra.integration.proxy import com.google.gson.Gson import com.google.gson.JsonObject import com.google.gson.reflect.TypeToken +import io.ably.lib.uts.infra.integration.SandboxApp import io.ably.lib.uts.infra.unit.ClientOptionsBuilder import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO @@ -218,8 +219,8 @@ class ProxySession private constructor( rules: List = emptyList(), port: Int = 0, timeoutMs: Long? = null, - realtimeHost: String = ProxyManager.sandboxRealtimeHost, - restHost: String = ProxyManager.sandboxRestHost, + realtimeHost: String = SandboxApp.sandboxHost, + restHost: String = SandboxApp.sandboxHost, ): ProxySession { val body = JsonObject().apply { add("target", JsonObject().apply { diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/ChannelHistoryTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/ChannelHistoryTest.kt new file mode 100644 index 000000000..56b8f39d7 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/ChannelHistoryTest.kt @@ -0,0 +1,140 @@ +package io.ably.lib.uts.integration.standard.realtime + +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.ConnectionState +import io.ably.lib.types.AblyException +import io.ably.lib.types.Callback +import io.ably.lib.types.ErrorInfo +import io.ably.lib.types.Message +import io.ably.lib.types.PaginatedResult +import io.ably.lib.types.PublishResult +import io.ably.lib.uts.infra.awaitChannelState +import io.ably.lib.uts.infra.awaitState +import io.ably.lib.uts.infra.integration.SandboxApp +import io.ably.lib.uts.infra.pollUntil +import io.ably.lib.uts.infra.unit.TestRealtimeClient +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.util.UUID +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Direct-sandbox integration test against the Ably Sandbox + * (`sandbox.realtime.ably-nonprod.net`, via [SandboxApp.sandboxHost]) — no proxy, no + * fault injection. Provisions one throwaway [SandboxApp] for the suite and connects real realtime + * clients straight to the sandbox. + * + * Verifies that messages published by one realtime client are available in the history retrieved by + * a separate client (cross-client durability). + * + * Spec points: RTL10d. Source spec: `realtime/integration/channel_history_test.md`. + * + * The test runs once per protocol variant (JSON / msgpack) per the spec's `PROTOCOL` dimension — + * realised here with a `useBinaryProtocol` [ParameterizedTest] parameter. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ChannelHistoryTest { + + private lateinit var app: SandboxApp + + @BeforeAll + fun setUpAll() = runBlocking { + app = SandboxApp.create() + } + + @AfterAll + fun tearDownAll() = runBlocking { + if (::app.isInitialized) app.delete() + } + + /** + * @UTS realtime/integration/RTL10d/history-cross-client-0 + */ + @ParameterizedTest(name = "useBinaryProtocol={0}") + @ValueSource(booleans = [false, true]) + fun `RTL10d - history contains messages published by another client`(useBinaryProtocol: Boolean) = runTest { + val channelName = "history-RTL10d-" + UUID.randomUUID() + val publisher = newClient(useBinaryProtocol) + val subscriber = newClient(useBinaryProtocol) + try { + publisher.connect() + subscriber.connect() + awaitState(publisher, ConnectionState.connected, 10.seconds) + awaitState(subscriber, ConnectionState.connected, 10.seconds) + + val pubChannel = publisher.channels.get(channelName) + val subChannel = subscriber.channels.get(channelName) + + pubChannel.attach() + subChannel.attach() + awaitChannelState(pubChannel, ChannelState.attached, 10.seconds) + awaitChannelState(subChannel, ChannelState.attached, 10.seconds) + + // Publish messages from the publisher client and await delivery confirmation. + pubChannel.awaitPublish("event1", "data1") + pubChannel.awaitPublish("event2", "data2") + pubChannel.awaitPublish("event3", "data3") + + // Retrieve history from the subscriber client, polling until all messages appear. + var history: PaginatedResult? = null + pollUntil(10.seconds, 500.milliseconds) { + val result = subChannel.history(null) + history = result + result.items().size == 3 + } + + val items = history!!.items() + assertEquals(3, items.size) + + // Default order is backwards (newest first). + assertEquals("event3", items[0].name) + assertEquals("data3", items[0].data) + + assertEquals("event2", items[1].name) + assertEquals("data2", items[1].data) + + assertEquals("event1", items[2].name) + assertEquals("data1", items[2].data) + } finally { + publisher.close() + subscriber.close() + } + } + + // ── helpers ────────────────────────────────────────────────────────────── + + /** A realtime client wired straight to the nonprod sandbox (no proxy). */ + private fun newClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeClient { + key = app.defaultKey + realtimeHost = SandboxApp.sandboxHost + restHost = SandboxApp.sandboxHost + this.useBinaryProtocol = useBinaryProtocol + autoConnect = false + } + + /** Publishes a message and suspends until the server confirms delivery (or errors). */ + private suspend fun Channel.awaitPublish(name: String, data: Any?): PublishResult = + suspendCancellableCoroutine { cont -> + publish(name, data, object : Callback { + override fun onSuccess(result: PublishResult) { + if (cont.isActive) cont.resume(result) + } + + override fun onError(reason: ErrorInfo) { + if (cont.isActive) cont.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }) + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt new file mode 100644 index 000000000..f91d8da07 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt @@ -0,0 +1,120 @@ +package io.ably.lib.uts.integration.standard.realtime + +import io.ably.lib.realtime.ConnectionState +import io.ably.lib.rest.Auth +import io.ably.lib.uts.infra.awaitState +import io.ably.lib.uts.infra.integration.SandboxApp +import io.ably.lib.uts.infra.unit.TestRealtimeClient +import io.ably.lib.uts.infra.unit.TestRestClient +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds + +/** + * Direct-sandbox integration test against the Ably Sandbox + * (`sandbox.realtime.ably-nonprod.net`, via [SandboxApp.sandboxHost]) — no proxy, no + * fault injection. Provisions one throwaway [SandboxApp] for the suite and connects a real + * realtime client straight to the sandbox. + * + * End-to-end verification that `Auth#createTokenRequest` produces a signed `TokenRequest` that the + * Ably service accepts — proving the HMAC signature computation (RSA9g) is compatible with the + * server. A REST client signs the TokenRequest; a separate realtime client exchanges it (through + * its `authCallback`) for a token and connects, proving the server accepted it. + * + * Spec points: RSA9, RSA9a, RSA9g. Source spec: `realtime/integration/auth/token_request_test.md`. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TokenRequestTest { + + private lateinit var app: SandboxApp + + @BeforeAll + fun setUpAll() = runBlocking { + app = SandboxApp.create() + } + + @AfterAll + fun tearDownAll() = runBlocking { + if (::app.isInitialized) app.delete() + } + + /** + * @UTS realtime/integration/RSA9a/token-request-server-accepted-0 + * @UTS realtime/integration/RSA9g/token-request-server-accepted-0 + */ + @Test + fun `RSA9a, RSA9g - createTokenRequest produces server-accepted token`() = runTest { + // Client A signs TokenRequests locally with the API key (no network). + val creator = TestRestClient { + key = app.defaultKey + restHost = SandboxApp.sandboxHost + } + + // Client B connects using a TokenRequest produced by client A. + val client = TestRealtimeClient { + authCallback = Auth.TokenCallback { params -> creator.auth.createTokenRequest(params, null) } + realtimeHost = SandboxApp.sandboxHost + restHost = SandboxApp.sandboxHost + useBinaryProtocol = false + autoConnect = false + } + + try { + client.connect() + awaitState(client, ConnectionState.connected, 15.seconds) + + assertEquals(ConnectionState.connected, client.connection.state) + assertNotNull(client.connection.id) + assertNull(client.connection.reason) + } finally { + client.close() + runCatching { creator.close() } + } + } + + /** + * @UTS realtime/integration/RSA9/token-request-with-clientid-0 + */ + @Test + fun `RSA9 - createTokenRequest with clientId`() = runTest { + val testClientId = "token-request-client-" + UUID.randomUUID() + + val creator = TestRestClient { + key = app.defaultKey + restHost = SandboxApp.sandboxHost + } + + // The TokenRequest is signed with the specific clientId, producing a token that + // authenticates the client with that identity. + val client = TestRealtimeClient { + authCallback = Auth.TokenCallback { params -> + params.clientId = testClientId + creator.auth.createTokenRequest(params, null) + } + clientId = testClientId + realtimeHost = SandboxApp.sandboxHost + restHost = SandboxApp.sandboxHost + useBinaryProtocol = false + autoConnect = false + } + + try { + client.connect() + awaitState(client, ConnectionState.connected, 15.seconds) + + assertEquals(ConnectionState.connected, client.connection.state) + assertEquals(testClientId, client.auth.clientId) + } finally { + client.close() + runCatching { creator.close() } + } + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/helpers.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/Helpers.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/helpers.kt rename to uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/Helpers.kt From 6f2c56ddee7aaf6d796de4df017b62f4fa78d9c7 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Tue, 30 Jun 2026 15:28:50 +0530 Subject: [PATCH 38/40] docs/test: apply CodeRabbit + Copilot PR review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - objects-mapping.md: correct helper filename casing helpers.kt -> Helpers.kt (broken source-nav on case-sensitive filesystems); tag bare code fences as `text` to clear markdownlint MD040. - README.md: tag bare fences as `text`; correct the msgpack rationale — ably-java does implement msgpack (useBinaryProtocol = true by default), the real reason proxy tests force JSON is that the proxy only handles text WebSocket frames. - index.html: mirror the README msgpack correction. - SKILL.md: label the unit-tier test scaffold as such and point integration/proxy flows at the SandboxApp/ProxySession section. - resolve_uts.py: validate the module path via Path.parts instead of a forward-slash-only regex, so Windows paths are accepted. - ConnectionRecoveryTest: use CopyOnWriteArrayList for the query-param capture list (written on the SDK transport thread, read on the coroutine dispatcher) to remove a visibility race / flake risk. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/uts-to-kotlin/SKILL.md | 6 +++++- .../references/objects-mapping.md | 18 +++++++++--------- .../uts-to-kotlin/scripts/resolve_uts.py | 9 ++++++--- uts/README.md | 18 ++++++++++-------- uts/index.html | 2 +- .../unit/realtime/ConnectionRecoveryTest.kt | 5 ++++- 6 files changed, 35 insertions(+), 23 deletions(-) diff --git a/.claude/skills/uts-to-kotlin/SKILL.md b/.claude/skills/uts-to-kotlin/SKILL.md index 7f24cb130..3850256f0 100644 --- a/.claude/skills/uts-to-kotlin/SKILL.md +++ b/.claude/skills/uts-to-kotlin/SKILL.md @@ -347,7 +347,11 @@ fun `RTN4a - description of what is being tested`() = runTest { } ``` -### File template +### File template (unit tier) + +This scaffold is for the **unit** tier — it wires the mocked transport (`infra.unit.*`, `MockWebSocket`, +`ConnectionDetails`). For the **integration** (direct sandbox) and **proxy** tiers, start from the +**Proxy integration tests** section instead (`SandboxApp` / `ProxySession` wiring), not from this template. ```kotlin package // the resolver's package for the chosen tier (Step 2) diff --git a/.claude/skills/uts-to-kotlin/references/objects-mapping.md b/.claude/skills/uts-to-kotlin/references/objects-mapping.md index cbe977f0c..90a6cbdbb 100644 --- a/.claude/skills/uts-to-kotlin/references/objects-mapping.md +++ b/.claude/skills/uts-to-kotlin/references/objects-mapping.md @@ -134,7 +134,7 @@ returns `null` if `k` isn't a number, rather than throwing. `values()` return `Iterable<…>`. The spec's tuple-destructuring loops and `IN` membership map to Kotlin directly: -``` +```text # spec FOR [key, pathObj] IN root.entries(): … ASSERT "name" IN root.keys() @@ -188,7 +188,7 @@ root's is `""`. A literal dot *inside* a segment is escaped as `\.`, and `at()` literal dot — so `path()` round-trips. Mind Kotlin's own backslash escaping (`"a\\.b.c"` is the string `a\.b.c`): -``` +```text # spec # ably-java (Kotlin) ASSERT root.path() == "" assertEquals("", root.path()) ASSERT root.get("a").get("b").path() assertEquals("a.b", root.get("a").asLiveMap().get("b").path()) @@ -280,7 +280,7 @@ etc. when a spec asserts on a constructed value's contents. Putting §4 + §6 together — the canonical write translations: -``` +```text # spec AWAIT root.set("count", LiveCounter.create(0)) AWAIT root.get("count").increment(5) @@ -313,7 +313,7 @@ runtime failure the spec is testing. To translate these, cast to the view whose write method you need (the `PathObject` cast never throws, `RTTS5d`), then assert the **operation** throws — that's where the `92007` surfaces: -``` +```text # spec: increment on a map fails AWAIT root.increment(5) FAILS WITH error # code 92007 ``` @@ -415,7 +415,7 @@ getter matching the action — `getMapCreate()`, `getMapSet()`, `getMapRemove()` **The spec accesses these as dotted property chains and compares `action` to a *string literal*; ably-java uses getters and an *enum constant*.** Translate the chain getter-by-getter and the string tag to the enum: -``` +```text # spec ASSERT msg.operation.action == "MAP_SET" ASSERT msg.operation.mapSet.key == "name" @@ -527,14 +527,14 @@ wire/message classes **by reflection** at runtime. Consequences when translating `DefaultLiveMapPathObject` / `DefaultInstance` / …, wire form is `WireObjectMessage` / `WireObjectOperation` / `WireObjectState` etc. -### Unit-test helpers — `standard_test_pool.md` → `helpers.kt` +### Unit-test helpers — `standard_test_pool.md` → `Helpers.kt` Every objects unit spec opens with `setup_synced_channel` and constructs protocol/object messages with the `build_*` helpers. These are implemented in -`uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/helpers.kt` — **call them; don't hand-roll the mock +`uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/Helpers.kt` — **call them; don't hand-roll the mock setup or message JSON.** -| Spec helper | `helpers.kt` | +| Spec helper | `Helpers.kt` | |---|---| | `{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test")` | `val (client, channel, root, mockWs) = setupSyncedChannel("test")` (`suspend`, returns `SyncedChannel`) | | `setup_synced_channel_no_ack(...)` | `setupSyncedChannelNoAck(...)` | @@ -560,7 +560,7 @@ wire `action` / `semantics` are integer enum codes — the builders emit the cod Spec pseudocode (public-API style): -``` +```text test "increments a nested counter and observes it" root = AWAIT channel.object.get() AWAIT root.set("game", LiveMap.create({ score: LiveCounter.create(0) })) diff --git a/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py b/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py index a61d0d91c..354bb1046 100644 --- a/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py +++ b/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py @@ -92,12 +92,15 @@ def main(): ) args = ap.parse_args() - raw = args.module_dir.rstrip("/") - if not re.search(r"/uts/[^/]+$", raw): + # Validate via Path.parts so this works regardless of separator (Windows '\' as well as + # POSIX '/'); a hard-coded "/uts/$" regex would reject otherwise-valid Windows paths. + module_dir = Path(args.module_dir) + raw = str(module_dir) + parts = module_dir.parts + if len(parts) < 2 or parts[-2] != "uts": fail("NOT_A_UTS_MODULE_PATH", f"{raw!r} is not a module directory directly under uts/ " f"(expected .../uts/).") - module_dir = Path(raw) if not module_dir.is_dir(): fail("DIR_NOT_FOUND", f"{raw!r} does not exist or is not a directory.") if not (module_dir / "unit").is_dir() and not (module_dir / "integration").is_dir(): diff --git a/uts/README.md b/uts/README.md index c34c84ddb..462fd87a0 100644 --- a/uts/README.md +++ b/uts/README.md @@ -39,7 +39,7 @@ leave gaps. UTS fixes this by separating **what to test** from **how to test it in a given language**: -``` +```text ┌──────────────────────────────┐ │ Ably features spec │ ← the ultimate authority (RSC*, RTN*, RTL* …) │ (features.md) │ @@ -98,10 +98,12 @@ Key principles (from [`integration-testing.md`](https://github.com/ably/specific - **Proxy tests prefer "late fault injection".** Let the real handshake complete against the real server, *then* inject the fault as the final interaction. This maximises how much of the test exercises genuine client-server behaviour (otherwise you've just written a slow unit test). -- **Proxy tests always use JSON** (`useBinaryProtocol = false`). The spec corpus gives two reasons: - the proxy only supports **text** WebSocket frames so it can't inspect/modify msgpack - ([`integration-testing.md`](https://github.com/ably/specification/blob/main/uts/docs/integration-testing.md) §Protocol Variants), and the SDK under test doesn't implement msgpack - ([`helpers/proxy.md`](https://github.com/ably/specification/blob/main/uts/realtime/integration/helpers/proxy.md)). +- **Proxy tests always use JSON** (`useBinaryProtocol = false`). ably-java *does* implement msgpack (it's + the default — `ClientOptions.useBinaryProtocol = true`); the real constraint is the proxy, which only + understands **text** WebSocket frames and so can't inspect or modify binary msgpack. The tests therefore + force JSON regardless of SDK support + ([`integration-testing.md`](https://github.com/ably/specification/blob/main/uts/docs/integration-testing.md) §Protocol Variants, + [`helpers/proxy.md`](https://github.com/ably/specification/blob/main/uts/realtime/integration/helpers/proxy.md)). --- @@ -221,7 +223,7 @@ mocked-transport tests, and `integration/` for real-backend tests — the latter `standard/` (direct sandbox, happy-path) and `proxy/` (sandbox through the fault-injecting proxy). Under each, a per-module folder (`realtime`, `liveobjects`, …) holds the actual test classes: -``` +```text uts/src/test/kotlin/io/ably/lib/uts/ ├── deviations.md # the catalogue of SDK-vs-spec divergences │ @@ -847,7 +849,7 @@ and record in `deviations.md`. A unit test installs `MockWebSocket` into `DebugOptions.webSocketEngineFactory`. The SDK believes it is talking to a real socket; in fact every byte is intercepted by the mock and surfaced to the test. -``` +```text ┌──────────────────────────────────── TEST (Kotlin coroutine) ────────────────────────────────────┐ │ │ │ TestRealtimeClient { install(mock); autoConnect = false } │ @@ -885,7 +887,7 @@ is talking to a real socket; in fact every byte is intercepted by the mock and s A proxy test uses the **real** SDK transport but points its host/port at the local `uts-proxy` process, which forwards to the Ably sandbox and can inject faults on command. -``` +```text ┌─────────────────── TEST (Kotlin) ───────────────────┐ │ @BeforeAll: ProxyManager.ensureProxy() │ downloads/launches binary, control :10100 │ SandboxApp.create() ─────────────────────────────────────────────┐ POST /apps (direct, TLS) diff --git a/uts/index.html b/uts/index.html index 70dcca0f2..88d2a0e52 100644 --- a/uts/index.html +++ b/uts/index.html @@ -200,7 +200,7 @@

    2 The Three Test Tiers

    • Integration tests do not replace unit tests. A spec point covered by a proxy test should also have a unit test. The unit test proves client logic; the proxy test proves client & real server agree.
    • Proxy tests prefer "late fault injection". Let the real handshake complete against the real server, then inject the fault as the final interaction — maximising how much of the test exercises genuine client-server behaviour.
    • -
    • Proxy tests always use JSON (useBinaryProtocol = false). Two reasons in the spec corpus: the proxy only supports text WebSocket frames so it can't inspect/modify msgpack (integration-testing.md), and the SDK under test doesn't implement msgpack (helpers/proxy.md).
    • +
    • Proxy tests always use JSON (useBinaryProtocol = false). ably-java does implement msgpack (it's the default — ClientOptions.useBinaryProtocol = true); the real constraint is the proxy, which only understands text WebSocket frames and so can't inspect or modify binary msgpack. The tests force JSON regardless of SDK support (integration-testing.md, helpers/proxy.md).
    diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt index 9dd55ec44..e39bc68b4 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt @@ -11,6 +11,7 @@ import io.ably.lib.uts.infra.awaitState import io.ably.lib.uts.infra.pollUntil import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest +import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.* import kotlin.time.Duration.Companion.seconds @@ -215,7 +216,9 @@ class ConnectionRecoveryTest { fun `RTN16k - recover option adds recover query param to WebSocket URL`() = runTest { val recoveryKeyJson = RecoveryKeyContext("recovered-key-xyz", 5, emptyMap()).encode() - val capturedQueryParams = mutableListOf>() + // Written from onConnectionAttempt (SDK transport thread) and read from pollUntil/asserts + // (coroutine dispatcher), so use a thread-safe list to avoid a visibility race / flaky reads. + val capturedQueryParams = CopyOnWriteArrayList>() var connectAttempt = 0 val mock = MockWebSocket { onConnectionAttempt = { conn -> From d11eb61b6504e79405dfd516db45db2ae04abf3f Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Tue, 30 Jun 2026 15:56:57 +0530 Subject: [PATCH 39/40] feat(uts-skill): add deterministic spec-faithfulness audit to Step 7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translation review (SKILL.md Step 7) relied on eyeballing the spec and the generated Kotlin side-by-side to confirm every test case, setup step, operation and assertion was carried over — exactly the mechanical comparison the model does inconsistently. Add a bundled extractor so the review reconciles a concrete ledger instead. scripts/audit_translation.py (regex-only, no semantic judgement, deterministic): - idCoverage: spec `**Test ID**` set vs Kotlin `@UTS` tags -> missing / orphan. - perTest[].sections: every non-comment pseudo-block line, grouped by section and tagged assert / await / step, so setup + operations + assertions are all enumerated; assertionShortfall flags likely-dropped assertions. - Robustness: never crashes — tolerant decoding plus a top-level guard always emit one JSON object. Exit 0 clean / 2 missing-or-orphan / 64 couldn't-run, via a fail() helper matching resolve_uts.py's error shape. SKILL.md Step 7 now runs the audit first and reconciles its output line by line (coverage + completeness), keeping setup-fidelity and deviation-honesty as the semantic checks; paths reference the resolver's specs[].file / targetDir. --- .claude/skills/uts-to-kotlin/SKILL.md | 74 +++++- .../scripts/audit_translation.py | 242 ++++++++++++++++++ 2 files changed, 304 insertions(+), 12 deletions(-) create mode 100644 .claude/skills/uts-to-kotlin/scripts/audit_translation.py diff --git a/.claude/skills/uts-to-kotlin/SKILL.md b/.claude/skills/uts-to-kotlin/SKILL.md index 3850256f0..232bd5e75 100644 --- a/.claude/skills/uts-to-kotlin/SKILL.md +++ b/.claude/skills/uts-to-kotlin/SKILL.md @@ -488,21 +488,70 @@ Append to `uts/src/test/kotlin/io/ably/lib/uts/deviations.md`. Each entry needs: ## Step 7 — Review generated output against the spec -Re-read the original spec file and the generated Kotlin test file side-by-side and check every item below. Fix anything that fails a check before declaring the task done. +The translation isn't done when it compiles — it's done when **every line of the source spec is faithfully +represented** in the Kotlin: each test case, each setup step, each operation, and each assertion. Missing a +single `ASSERT` produces a test that compiles, passes, and silently checks less than the spec demands. This +review runs in **both** modes (it's static — it doesn't need the tests to have run). -### Coverage check — every test case is present +### Deterministic faithfulness audit — run the script first -For each test case ID in the spec: -- [ ] A `@Test` method exists with that ID in its `@UTS` KDoc tag -- [ ] The method name contains the spec ID and a meaningful description +Eyeballing two files for "did I translate every line?" is exactly the kind of mechanical comparison the +model does inconsistently, so a bundled script extracts the ledger for you — same inputs, same report every +time. Run it for each translated spec — don't hand-build paths; use the resolver's output (Step A): the +**source spec file** is that spec's `specs[].file` (already an absolute path) and the **generated Kotlin +file** is the chosen tier's `targetDir` + `className` (Step 2): -Flag any spec test case that has no corresponding `@Test` method as **missing** and implement it. +```bash +python3 .claude/skills/uts-to-kotlin/scripts/audit_translation.py \ + "" \ + "/.kt" +``` -### Assertion completeness — every `ASSERT` / `AWAIT` is translated +It prints one JSON report and exits non-zero (2) when there are missing or orphan Test IDs. It does **no** +semantic judgement — it only extracts, deterministically, what you must then reconcile by hand: + +- **`idCoverage`** — the spec's `**Test ID**` set vs the Kotlin's `@UTS` tag set. + - `missingInKotlin` **must be empty.** Each entry is a spec test case with no `@Test` method — implement it. + - `orphanInKotlin` **must be empty** (or every entry explained) — an `@UTS` tag that no longer matches any + spec Test ID means a stale or hand-edited tag. +- **`perTest[]`** — for each spec test case, every non-comment code line inside the spec's `pseudo` blocks, + **grouped by section** (`Setup` / `Test Steps` / `Assertions` / …) in `sections[]` and each line tagged + `assert` (an `ASSERT*` outcome), `await` (an `AWAIT*` / `EXPECT` wait), or `step` (setup, mock/client + construction, an operation). `specAsserts` / `specAwaits` are flat convenience views of the first two; + `specCodeLineTotal` is the size of the spec test. The matching Kotlin method's assertion/await/poll calls + and their count come alongside. + +The audit is a review aid, not a gate — it never crashes, it degrades. It always prints one JSON object +(an `error` field instead of a report if a file is unreadable). If `idCoverage.specCount` is `0` for a file +you know is a real spec, the extractor couldn't find any `**Test ID**` markers (an unusual spec format) — +treat that as "couldn't verify deterministically" and fall back to a manual side-by-side read for that file. + +### Coverage check — every test case is present -For each `ASSERT`, `AWAIT`, or observable outcome stated in the spec pseudocode: -- [ ] There is a direct Kotlin assertion (`assertEquals`, `assertNotNull`, `assertNull`, `assertIs`, `assertTrue`, `assertFailsWith`, etc.) or an `awaitState` / `awaitChannelState` call covering it -- [ ] No spec assertion has been silently dropped or weakened (e.g. replaced with a comment) +From the audit's `idCoverage`: +- [ ] `missingInKotlin` is empty (every spec Test ID has an `@Test` with that ID in its `@UTS` KDoc tag) +- [ ] `orphanInKotlin` is empty, or each orphan is a deliberate, explained rename +- [ ] Each method name contains the spec ID and a meaningful description + +### Line-by-line completeness — every spec line is translated + +Walk the audit's `perTest[].sections` and reconcile **each** extracted spec line against the Kotlin method. +This is the guarantee that no setup step, operation, or assertion was dropped — the whole point of this step: +- [ ] Every `assert` line maps to a concrete Kotlin assertion (`assertEquals`, `assertNotNull`, `assertNull`, + `assertIs`, `assertTrue`, `assertFailsWith`, …) — not a comment, not a weaker check +- [ ] Every `await` line (state waits **and** awaited operations like `AWAIT channel.attach()` / + `setup_synced_channel(...)`) is performed via `awaitState` / `awaitChannelState` / `pollUntil` or the + corresponding SDK call +- [ ] Every `step` line — client/mock construction, `ClientOptions`, installed mocks, channel ops — is + reflected in the test setup or body. Multi-line spec constructs split across several `step` lines; + reconcile them as a group. +- [ ] `assertionShortfall > 0` for a test (`summary.testsWithShortfall`) is a **tripwire** — fewer Kotlin + assertions than spec `ASSERT`s strongly suggests a dropped assertion; open that test and account for + each one. (A negative shortfall is fine — the SDK mapping often needs *more* Kotlin lines per spec + `ASSERT`, e.g. number-type normalisation.) + +A spec line you intentionally don't translate is only acceptable as a documented deviation (a `// DEVIATION:` +comment + a `deviations.md` entry), never as a silent omission. ### Setup fidelity — preconditions match the spec @@ -519,8 +568,9 @@ generated test diverges from the spec pseudocode (adapted assertion, env-gated s - [ ] A `// DEVIATION:` comment explains why - [ ] The deviation is recorded in `uts/src/test/kotlin/io/ably/lib/uts/deviations.md` -If you find gaps during this review, fix them and re-run Step 5 (compile) — and, in evaluate mode, Step 6 — -before finishing. +If you find gaps during this review, fix them, then **re-run the audit script** until `missingInKotlin` / +`orphanInKotlin` are empty and every `perTest` entry reconciles, and re-run Step 5 (compile) — and, in +evaluate mode, Step 6 — before finishing. --- diff --git a/.claude/skills/uts-to-kotlin/scripts/audit_translation.py b/.claude/skills/uts-to-kotlin/scripts/audit_translation.py new file mode 100644 index 000000000..10f634aeb --- /dev/null +++ b/.claude/skills/uts-to-kotlin/scripts/audit_translation.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +""" +audit_translation.py — deterministic faithfulness audit of a UTS spec against its +generated Kotlin test, so the per-spec review (SKILL.md Step 7) reconciles a concrete +extracted ledger instead of eyeballing two files. + +Usage: + python3 audit_translation.py + +It does ZERO semantic judgement — only mechanical extraction with regex, so the same +inputs always give the same report: + + 1. Test-ID coverage. Every `**Test ID**: \`\`` in the spec vs every `@UTS ` + in the Kotlin file. + - missingInKotlin: a spec Test ID with no matching @UTS tag → a whole test + case is absent; implement it (or consciously exclude it and explain why). + - orphanInKotlin: an @UTS tag with no matching spec Test ID → a stale or + hand-edited tag. Investigate. + + 2. Per-test line ledger. Within each spec test block (from one Test ID to the next), + every non-blank, non-comment code line inside the ```pseudo fences is extracted + verbatim, grouped by its section (Setup / Test Steps / Assertions / …) — so setup, + operations AND assertions are all enumerated, nothing escapes the ledger. Each line + is tagged: "assert" (ASSERT*), "await" (AWAIT* / EXPECT), or "step" (everything else + — setup, mock construction, operations). For convenience the ASSERT* and AWAIT* + lines are also surfaced flat as specAsserts / specAwaits. + Alongside them, the count of assertion / await / poll calls in the matching Kotlin + method is reported as a tripwire: Kotlin assertions < spec ASSERTs for a test is a + strong signal an assertion was silently dropped. + +Robustness contract: this tool must never crash mid-run on any spec/test pair. It is a +review aid — if it can't extract something it degrades to "couldn't verify" (fewer lines +in the ledger) rather than throwing. Whatever happens it emits ONE parseable JSON object, +never a traceback, so a caller can always rely on the output shape. + +Exit status: + 0 — clean audit (no missing/orphan Test IDs) + 2 — audit ran, but there are missing/orphan Test IDs (gateable) + 64 — could not run: bad usage, unreadable file, or an internal error (JSON carries `error`) +""" + +import json +import re +import sys +from pathlib import Path + +# Spec markers ------------------------------------------------------------- +TEST_ID_RE = re.compile(r"\*\*Test ID\*\*:\s*`([^`]+)`") +HEADING_RE = re.compile(r"^#{1,4}\s+(.*\S)\s*$") +FENCE_RE = re.compile(r"^\s*```") +# Imperative spec keywords. Order matters: longest / most specific first so AWAIT_STATE +# is classified before the bare AWAIT. +DIRECTIVE_RE = re.compile(r"\b(ASSERT_[A-Z_]+|ASSERT|AWAIT_STATE|AWAIT_ERROR|AWAIT_ALL|AWAIT|EXPECT)\b") + +# Kotlin markers ----------------------------------------------------------- +UTS_TAG_RE = re.compile(r"@UTS\s+(\S+)") +KOTLIN_ASSERT_RE = re.compile( + r"\b(assertEquals|assertNotEquals|assertNull|assertNotNull|assertTrue|assertFalse|" + r"assertIs|assertIsNot|assertContains|assertFailsWith|assertFails|assertSame|" + r"assertNotSame|awaitState|awaitChannelState|pollUntil)\b" +) + + +def fail(code, message, status=64, **extra): + """Emit a structured error (same JSON shape as resolve_uts.py) and exit. `status` + defaults to 64 — "couldn't run" — kept distinct from the audit's own 0 (clean) and + 2 (missing/orphan IDs) outcomes so callers can tell the two apart.""" + print(json.dumps({"ok": False, "error": code, "message": message, **extra}, indent=2)) + sys.exit(status) + + +def read_lines(path): + """Read a file into lines, tolerant of encoding issues — a stray non-UTF-8 byte in a + spec must never crash the audit, so undecodable bytes are replaced, not raised on.""" + return Path(path).read_text(encoding="utf-8", errors="replace").splitlines() + + +def classify(keyword): + if keyword.startswith("ASSERT"): + return "assert" + if keyword.startswith("AWAIT") or keyword == "EXPECT": + return "await" + return "other" + + +def line_kind(stripped): + """Tag a pseudocode line: assert / await / step.""" + m = DIRECTIVE_RE.search(stripped) + if m: + k = classify(m.group(1)) + if k in ("assert", "await"): + return k + return "step" + + +def parse_spec(path): + """Return an ordered list of test-id dicts, each: + {testId, title, sections:[{heading, lines:[{text, kind}]}], asserts[], awaits[], codeLineTotal}.""" + lines = read_lines(path) + + # locate each Test ID line and the nearest preceding heading (its human title) + tests = [] + last_heading = "" + for i, line in enumerate(lines): + h = HEADING_RE.match(line) + if h: + last_heading = h.group(1) + m = TEST_ID_RE.search(line) + if m: + tests.append({"testId": m.group(1), "title": last_heading, "_start": i}) + + # block boundaries: each test runs to the next test's start (or EOF) + for idx, t in enumerate(tests): + start = t["_start"] + end = tests[idx + 1]["_start"] if idx + 1 < len(tests) else len(lines) + + sections = [] # [{heading, lines:[{text, kind}]}] + cur = None # current section being filled + in_fence = False + for line in lines[start:end]: + h = HEADING_RE.match(line) + if h and not in_fence: + cur = {"heading": h.group(1), "lines": []} + sections.append(cur) + continue + if FENCE_RE.match(line): + in_fence = not in_fence + continue + if not in_fence: + continue + stripped = line.strip() + if not stripped or stripped.startswith("#"): # blank / pseudocode comment + continue + if cur is None: # code before any heading in the block (rare) + cur = {"heading": "(preamble)", "lines": []} + sections.append(cur) + cur["lines"].append({"text": stripped, "kind": line_kind(stripped)}) + + # drop sections with no code (prose-only headings, requirement tables, the title line) + sections = [s for s in sections if s["lines"]] + asserts = [ln["text"] for s in sections for ln in s["lines"] if ln["kind"] == "assert"] + awaits = [ln["text"] for s in sections for ln in s["lines"] if ln["kind"] == "await"] + t["sections"] = sections + t["asserts"] = asserts + t["awaits"] = awaits + t["codeLineTotal"] = sum(len(s["lines"]) for s in sections) + del t["_start"] + return tests + + +def parse_kotlin(path): + """Return dict: uts-id -> {assertionCalls[], assertionCount}. Method block for a tag + runs from its @UTS line to the next @UTS line (or EOF).""" + lines = read_lines(path) + tags = [(i, m.group(1)) for i, line in enumerate(lines) for m in [UTS_TAG_RE.search(line)] if m] + out = {} + for idx, (start, tag) in enumerate(tags): + end = tags[idx + 1][0] if idx + 1 < len(tags) else len(lines) + calls = [] + for line in lines[start:end]: + for m in KOTLIN_ASSERT_RE.finditer(line): + calls.append(m.group(1)) + out[tag] = {"assertionCalls": calls, "assertionCount": len(calls)} + return out + + +def main(): + if len(sys.argv) != 3: + fail("USAGE", "Usage: audit_translation.py ") + + spec_path, kt_path = sys.argv[1], sys.argv[2] + for p in (spec_path, kt_path): + if not Path(p).is_file(): + fail("FILE_NOT_FOUND", f"{p!r} not found.") + + # Robustness backstop: never let an unexpected parsing/IO error surface as a traceback. + # fail() emits structured JSON and exits 64 (distinct from the 0/2 audit outcomes) so + # callers and the model can tell "couldn't run" apart from "ran, found gaps". + try: + spec_tests = parse_spec(spec_path) + kotlin = parse_kotlin(kt_path) + report = build_report(spec_path, kt_path, spec_tests, kotlin) + except Exception as exc: # noqa: BLE001 — deliberate catch-all; this tool must not crash + fail("INTERNAL_ERROR", f"{type(exc).__name__}: {exc}", spec=spec_path, kotlin=kt_path) + + print(json.dumps(report, indent=2)) + cov = report["idCoverage"] + sys.exit(2 if (cov["missingInKotlin"] or cov["orphanInKotlin"]) else 0) + + +def build_report(spec_path, kt_path, spec_tests, kotlin): + spec_ids = [t["testId"] for t in spec_tests] + kt_ids = list(kotlin.keys()) + spec_id_set, kt_id_set = set(spec_ids), set(kt_ids) + missing = [i for i in spec_ids if i not in kt_id_set] + orphan = [i for i in kt_ids if i not in spec_id_set] + + per_test = [] + for t in spec_tests: + kt = kotlin.get(t["testId"]) + per_test.append({ + "testId": t["testId"], + "title": t["title"], + # every code line of the spec test, grouped by section (setup / steps / assertions), + # each tagged assert | await | step — the full "did I translate every line?" ledger + "sections": t["sections"], + "specCodeLineTotal": t["codeLineTotal"], + # flat convenience views of the observable lines + "specAsserts": t["asserts"], + "specAwaits": t["awaits"], + "specAssertCount": len(t["asserts"]), + "specAwaitCount": len(t["awaits"]), + "kotlinPresent": kt is not None, + "kotlinAssertionCalls": kt["assertionCalls"] if kt else [], + "kotlinAssertionCount": kt["assertionCount"] if kt else 0, + # tripwire: fewer Kotlin assertions than spec ASSERTs => likely a dropped assertion + "assertionShortfall": (len(t["asserts"]) - kt["assertionCount"]) if kt else len(t["asserts"]), + }) + + report = { + "ok": True, + "spec": spec_path, + "kotlin": kt_path, + "idCoverage": { + "specCount": len(spec_ids), + "kotlinCount": len(kt_ids), + "missingInKotlin": missing, + "orphanInKotlin": orphan, + }, + "perTest": per_test, + "summary": { + "specAssertTotal": sum(len(t["asserts"]) for t in spec_tests), + "specAwaitTotal": sum(len(t["awaits"]) for t in spec_tests), + "kotlinAssertionTotal": sum(k["assertionCount"] for k in kotlin.values()), + "testsWithShortfall": [p["testId"] for p in per_test if p["assertionShortfall"] > 0], + }, + } + return report + + +if __name__ == "__main__": + main() From e2ad9b584aba4786f1cebe27dfa0b60341afb77a Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Tue, 30 Jun 2026 16:11:16 +0530 Subject: [PATCH 40/40] fix(uts-skill): make resolve_uts.py file I/O Windows-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_text()/write_text() default to the locale encoding on Windows (often cp1252, not UTF-8) and apply newline translation, which would both mis-handle non-ASCII content and rewrite the git-tracked uts-package-mapping.json with CRLF. Pin the read to utf-8 and write via write_bytes (binary mode does zero newline translation on any OS or Python version, keeping the file LF — unlike write_text(newline=...), which needs Python 3.10+). Aligns with the utf-8 reads already in audit_translation.py. --- .claude/skills/uts-to-kotlin/scripts/resolve_uts.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py b/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py index 354bb1046..675b6ef93 100644 --- a/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py +++ b/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py @@ -112,7 +112,7 @@ def main(): if not MAPPING.is_file(): fail("MAPPING_NOT_FOUND", f"mapping file not found at {MAPPING}") - data = json.loads(MAPPING.read_text()) + data = json.loads(MAPPING.read_text(encoding="utf-8")) packages = data.setdefault("packages", {}) test_root = data.get("testRoot", "") @@ -133,7 +133,10 @@ def main(): if notes: new_entry["notes"] = notes packages[source_module] = new_entry - MAPPING.write_text(json.dumps(data, indent=2) + "\n") + # Write bytes (not write_text): explicit utf-8, and binary mode does zero newline + # translation on any OS or Python version — so this git-tracked file stays LF on + # Windows too. (write_text(newline=...) would need Python 3.10+.) + MAPPING.write_bytes((json.dumps(data, indent=2) + "\n").encode("utf-8")) mapped = source_module in packages entry = packages.get(source_module, {})