From 041043257b548e15a55996909c304f5bfa98f243 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 8 Jun 2026 18:09:24 +0530 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] - 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/16] 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/16] 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/16] 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/16] 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/16] - 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/16] 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/16] 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/16] 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/16] 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") }