From 041043257b548e15a55996909c304f5bfa98f243 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 8 Jun 2026 18:09:24 +0530 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6ba806c90c4793f527fec81a85aca1eef1242d99 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 11 Jun 2026 23:16:34 +0530 Subject: [PATCH 6/7] Added basic implementation for public interfaces in kotlin --- liveobjects/build.gradle.kts | 2 + .../io/ably/lib/object/DefaultLiveCounter.kt | 18 ++ .../io/ably/lib/object/DefaultLiveMap.kt | 19 ++ .../io/ably/lib/object/DefaultSubscription.kt | 21 ++ .../main/kotlin/io/ably/lib/object/Errors.kt | 50 +++++ .../io/ably/lib/object/ObjectsBridge.kt | 113 ++++++++++ .../io/ably/lib/object/ObjectsIdentifier.kt | 43 ++++ .../kotlin/io/ably/lib/object/ObjectsNode.kt | 32 +++ .../kotlin/io/ably/lib/object/PathFinder.kt | 49 +++++ .../object/PathObjectSubscriptionRegister.kt | 110 ++++++++++ .../kotlin/io/ably/lib/object/RootEntry.kt | 18 ++ .../io/ably/lib/object/ValueConversion.kt | 104 +++++++++ .../io/ably/lib/object/ValueTypeEvaluation.kt | 146 +++++++++++++ .../kotlin/io/ably/lib/object/WireJson.kt | 42 ++++ .../kotlin/io/ably/lib/object/WireModel.kt | 126 +++++++++++ .../object/instance/DefaultBaseInstance.kt | 68 ++++++ .../DefaultInstanceSubscriptionEvent.kt | 18 ++ .../instance/DefaultLiveCounterInstance.kt | 82 +++++++ .../object/instance/DefaultLiveMapInstance.kt | 123 +++++++++++ .../instance/DefaultPrimitiveInstances.kt | 79 +++++++ .../object/message/DefaultObjectMessage.kt | 158 ++++++++++++++ .../lib/object/path/DefaultBasePathObject.kt | 145 +++++++++++++ .../path/DefaultLiveCounterPathObject.kt | 66 ++++++ .../object/path/DefaultLiveMapPathObject.kt | 107 +++++++++ .../DefaultPathObjectSubscriptionEvent.kt | 18 ++ .../path/DefaultPrimitivePathObjects.kt | 68 ++++++ .../kotlin/io/ably/lib/object/unit/Fakes.kt | 58 +++++ .../io/ably/lib/object/unit/PathApiTest.kt | 204 ++++++++++++++++++ .../lib/object/unit/ValueTypeContractTest.kt | 95 ++++++++ 29 files changed, 2182 insertions(+) create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/DefaultLiveCounter.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/DefaultLiveMap.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/DefaultSubscription.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/Errors.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsBridge.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsIdentifier.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsNode.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/PathFinder.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/PathObjectSubscriptionRegister.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/RootEntry.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/ValueConversion.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/ValueTypeEvaluation.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/WireJson.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/WireModel.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultBaseInstance.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/DefaultLiveCounterInstance.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultLiveMapInstance.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultPrimitiveInstances.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/path/DefaultBasePathObject.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultLiveCounterPathObject.kt create mode 100644 liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultLiveMapPathObject.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/DefaultPrimitivePathObjects.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/object/unit/Fakes.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/object/unit/PathApiTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/object/unit/ValueTypeContractTest.kt diff --git a/liveobjects/build.gradle.kts b/liveobjects/build.gradle.kts index 5b45ce92e..171037d00 100644 --- a/liveobjects/build.gradle.kts +++ b/liveobjects/build.gradle.kts @@ -33,6 +33,8 @@ tasks.withType().configureEach { tasks.register("runLiveObjectUnitTests") { filter { includeTestsMatching("io.ably.lib.objects.unit.*") + // unit tests for the path-based public API implementation (io.ably.lib.object) + includeTestsMatching("io.ably.lib.object.unit.*") } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultLiveCounter.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultLiveCounter.kt new file mode 100644 index 000000000..f9880c9d2 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultLiveCounter.kt @@ -0,0 +1,18 @@ +package io.ably.lib.`object` + +import io.ably.lib.`object`.value.LiveCounter + +/** + * Implementation of the [LiveCounter] value type: an immutable holder for the + * initial count of a new LiveCounter object to be created by a mutation. + * + * Instantiated reflectively by `io.ably.lib.object.value.LiveCounter#create` — + * the class name and the single `(java.lang.Number)` constructor are a frozen + * contract with the `lib` module and must not change. + * + * Spec: RTLCV1, RTLCV2a, RTLCV3b, RTLCV3d + */ +public class DefaultLiveCounter(count: Number) : LiveCounter() { + /** Internal initial count (RTLCV2a). */ + internal val count: Number = count +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultLiveMap.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultLiveMap.kt new file mode 100644 index 000000000..ba6890204 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultLiveMap.kt @@ -0,0 +1,19 @@ +package io.ably.lib.`object` + +import io.ably.lib.`object`.value.LiveMap +import io.ably.lib.`object`.value.LiveMapValue + +/** + * Implementation of the [LiveMap] value type: an immutable holder for the + * initial entries of a new LiveMap object to be created by a mutation. + * + * Instantiated reflectively by `io.ably.lib.object.value.LiveMap#create` — + * the class name and the single `(java.util.Map)` constructor are a frozen + * contract with the `lib` module and must not change. + * + * Spec: RTLMV1, RTLMV2a, RTLMV3b, RTLMV3d + */ +public class DefaultLiveMap(entries: Map) : LiveMap() { + /** Internal initial entries (RTLMV2a); defensively copied for immutability (RTLMV3d). */ + internal val entries: Map = HashMap(entries) +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultSubscription.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultSubscription.kt new file mode 100644 index 000000000..9149096da --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultSubscription.kt @@ -0,0 +1,21 @@ +package io.ably.lib.`object` + +import io.ably.lib.`object`.Subscription + +/** + * Implementation of the public [Subscription] handle returned by the + * `subscribe` methods of the path/instance APIs. + * + * Spec: SUB1, SUB2a, SUB2b (idempotent unsubscribe) + */ +internal class DefaultSubscription(private val onUnsubscribe: () -> Unit) : Subscription { + + @Volatile + private var unsubscribed = false + + override fun unsubscribe() { + if (unsubscribed) return // SUB2b - subsequent calls are no-ops + unsubscribed = true + onUnsubscribe() + } +} 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..576ecd453 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/Errors.kt @@ -0,0 +1,50 @@ +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), + 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 +} + +internal enum class ObjectHttpStatusCode(val code: Int) { + BadRequest(400), + InternalServerError(500), +} + +internal fun objectsException( + 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) = + objectsException(message, ObjectErrorCode.InvalidInputParams) + +/** ErrorInfo 400 / 92005 - write operation on an unresolvable path (RTPO3c2). */ +internal fun pathNotResolvedError(path: String) = + objectsException("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) = + objectsException(message, ObjectErrorCode.ObjectsTypeMismatch) + +/** ErrorInfo 500 / 92000 - invalid internal object state. */ +internal fun objectStateError(message: String) = + objectsException(message, ObjectErrorCode.InvalidObject, ObjectHttpStatusCode.InternalServerError) diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsBridge.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsBridge.kt new file mode 100644 index 000000000..f1bd4e505 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsBridge.kt @@ -0,0 +1,113 @@ +package io.ably.lib.`object` + +import io.ably.lib.util.Log +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import io.ably.lib.types.AblyException +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.launch + +/** + * The single abstract seam between this package and the realtime objects + * system. The path/instance implementation classes depend ONLY on this + * contract; a bridge (implemented outside this package, alongside the + * realtime internals) provides the graph views, preconditions, publishing and + * update fan-in. This keeps `io.ably.lib.object` free of any dependency on + * `io.ably.lib.objects`. + */ +internal abstract class ObjectsBridge { + + /** The channel this objects instance belongs to (used for PAOM2e/PAOM3b). */ + internal abstract val channelName: String + + /** The root InternalLiveMap view (objectId `root`), or null if unavailable. Spec: RTO3, RTPO2b */ + internal abstract fun getRootNode(): MapNode? + + /** Looks up a non-tombstoned object view by id, or null. Spec: RTO3a */ + internal abstract fun getNode(objectId: String): ObjectsNode? + + /** Access API preconditions; throws ErrorInfo-carrying AblyException on failure. Spec: RTO25 */ + internal abstract fun throwIfInvalidAccessApiConfiguration() + + /** Write API preconditions; throws ErrorInfo-carrying AblyException on failure. Spec: RTO26 */ + internal abstract fun throwIfInvalidWriteApiConfiguration() + + /** Publishes the messages and applies them locally on ACK. Spec: RTO15, RTO20 */ + internal abstract suspend fun publish(messages: List) + + /** Current server time in epoch milliseconds. Spec: RTO16 */ + internal abstract suspend fun getServerTime(): Long + + /** Ensures the channel is attached and objects are SYNCED. Spec: RTO23b, RTO23c, RTO23e */ + internal abstract suspend fun ensureAttachedAndSynced() + + /** + * Registry for path-based subscriptions (RTPO19). Bridge implementations + * feed it via [notifyUpdated]. + */ + internal val pathSubscriptionRegister = PathObjectSubscriptionRegister(this) + + /** Scope used to expose suspend write operations as CompletableFutures. */ + private val asyncScope = + CoroutineScope(Dispatchers.Default + CoroutineName("ObjectsBridge") + SupervisorJob()) + + /** Per-object message-carrying update listeners (instance subscriptions, RTINS16). */ + private val updateListeners = + ConcurrentHashMap, WireObjectMessage?) -> Unit>>() + + /** + * Subscribes to updates applied to the object with [objectId]. The listener + * receives the set of updated map keys (empty for counters) and the source + * message when the update originated from an operation. Returns an + * unsubscribe handle. + */ + internal fun subscribeToUpdates(objectId: String, listener: (Set, WireObjectMessage?) -> Unit): () -> Unit { + val listeners = updateListeners.computeIfAbsent(objectId) { CopyOnWriteArrayList() } + listeners.add(listener) + return { listeners.remove(listener) } + } + + /** + * Entry point for bridge implementations: call after an update has been + * applied to an object, with the keys that changed (empty for counters) and + * the source ObjectMessage when the update came from an operation (null for + * sync-induced changes). Fans out to instance subscriptions (RTINS16) and + * path subscriptions (RTPO19). + */ + internal fun notifyUpdated(objectId: String, updatedKeys: Set, message: WireObjectMessage?) { + updateListeners[objectId]?.forEach { listener -> + try { + listener(updatedKeys, message) + } catch (t: Throwable) { + Log.e("ObjectsBridge", "Error in update listener for objectId=$objectId", t) + } + } + pathSubscriptionRegister.notifyObjectUpdated(objectId, updatedKeys, message) + } + + /** + * Runs a suspend write and exposes it as a CompletableFuture; + * failures complete exceptionally with the underlying AblyException. + */ + internal fun launchWithVoidFuture(block: suspend () -> Unit): CompletableFuture { + val future = CompletableFuture() + asyncScope.launch { + try { + block() + future.complete(null) + } catch (throwable: Throwable) { + when (throwable) { + is AblyException -> future.completeExceptionally(throwable) + else -> future.completeExceptionally( + objectsException("Error executing operation", ObjectErrorCode.BadRequest, cause = throwable) + ) + } + } + } + return future + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsIdentifier.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsIdentifier.kt new file mode 100644 index 000000000..cb68ded75 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsIdentifier.kt @@ -0,0 +1,43 @@ +package io.ably.lib.`object` + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.Base64 + +/** Object type discriminator used in objectId generation. Spec: RTO14 */ +internal enum class WireObjectType(val value: String) { + Map("map"), + Counter("counter"), +} + +/** + * ObjectId generation for client-created objects. Copied from the legacy + * `io.ably.lib.objects.ObjectId` so this package has no dependency on it - + * the format `type:base64url(sha256(initialValue:nonce))@msTimestamp` is a + * wire contract. + * + * Spec: RTO14, RTO6b1 + */ +internal object ObjectsIdentifier { + internal fun fromInitialValue( + objectType: WireObjectType, + initialValue: String, + nonce: String, + msTimestamp: Long, + ): String { + val valueForHash = "$initialValue:$nonce".toByteArray(StandardCharsets.UTF_8) + // RTO14b - hash the initial value and nonce to create a unique identifier + val hashBytes = MessageDigest.getInstance("SHA-256").digest(valueForHash) + val urlSafeHash = Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes) + return "${objectType.value}:$urlSafeHash@$msTimestamp" + } +} + +/** + * Generates a random nonce string for object creation (16 alphanumeric chars). + * Copied from the legacy `generateNonce`. Spec: RTLMV4g, RTLCV4d + */ +internal fun generateObjectNonce(): String { + val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + return (1..16).map { chars.random() }.joinToString("") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsNode.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsNode.kt new file mode 100644 index 000000000..682616262 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsNode.kt @@ -0,0 +1,32 @@ +package io.ably.lib.`object` + +/** + * Abstract view over the live objects graph, implemented by the bridge that + * connects this package to the realtime objects system (kept abstract so this + * package has no dependency on `io.ably.lib.objects`). + * + * Contract for implementations: + * - tombstoned objects are never returned by [ObjectsBridge.getNode] / + * [ObjectsBridge.getRootNode]; + * - [MapNode.entries] / [MapNode.get] expose only non-tombstoned entries that + * carry data; values referencing other objects carry `objectId` in their + * [WireObjectData]. + */ +internal interface ObjectsNode { + val objectId: String +} + +/** View over an InternalLiveMap (RTLM1). */ +internal interface MapNode : ObjectsNode { + /** Snapshot of the current non-tombstoned entries. */ + fun entries(): Map + + /** The current non-tombstoned entry for [key], or null. */ + fun get(key: String): WireObjectData? +} + +/** View over an InternalLiveCounter (RTLC1). */ +internal interface CounterNode : ObjectsNode { + /** The current counter value (RTLC5). */ + fun count(): Double +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/PathFinder.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/PathFinder.kt new file mode 100644 index 000000000..f6519f18e --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/PathFinder.kt @@ -0,0 +1,49 @@ +package io.ably.lib.`object` + +/** + * Computes every path from the root map to a target object by walking the + * objects graph on demand (over objectId references), instead of maintaining + * incremental parent references like ably-js does (RTLO3f/RTLO4g/RTLO4h). + * RTLO4f-equivalent observable behavior; cycle-safe. + * + * Spec: RTLO4f (equivalent) + */ +internal object PathFinder { + + /** + * Returns all paths (as segment lists) from the root map to the object with + * [targetObjectId]. The root itself yields a single empty path. + */ + internal fun findFullPaths(bridge: ObjectsBridge, targetObjectId: String): List> { + val root = bridge.getRootNode() ?: return emptyList() + if (targetObjectId == root.objectId) return listOf(emptyList()) + val result = mutableListOf>() + walk(bridge, root, targetObjectId, currentPath = mutableListOf(), visited = mutableSetOf(), result) + return result + } + + private fun walk( + bridge: ObjectsBridge, + map: MapNode, + targetObjectId: String, + currentPath: MutableList, + visited: MutableSet, + result: MutableList>, + ) { + if (!visited.add(map.objectId)) return // cycle guard + for ((key, data) in map.entries()) { + val refId = data.objectId ?: continue + if (refId == targetObjectId) { + result.add(currentPath + key) + continue + } + val refNode = bridge.getNode(refId) + if (refNode is MapNode) { + currentPath.add(key) + walk(bridge, refNode, targetObjectId, currentPath, visited, result) + currentPath.removeAt(currentPath.size - 1) + } + } + visited.remove(map.objectId) + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/PathObjectSubscriptionRegister.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/PathObjectSubscriptionRegister.kt new file mode 100644 index 000000000..6240580c9 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/PathObjectSubscriptionRegister.kt @@ -0,0 +1,110 @@ +package io.ably.lib.`object` + +import io.ably.lib.`object`.message.toPublicMessage +import io.ably.lib.`object`.path.DefaultPathObject +import io.ably.lib.`object`.path.DefaultPathObjectSubscriptionEvent +import io.ably.lib.`object`.path.PathObjectListener +import io.ably.lib.`object`.path.PathObjectSubscriptionOptions +import io.ably.lib.util.Log +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +/** + * Registry for path-based subscriptions: stores listeners keyed by + * subscription id, matches applied object updates against subscription paths + * using the depth coverage rule, and delivers PathObjectSubscriptionEvents. + * + * Mirrors ably-js `pathobjectsubscriptionregister.ts`. + * + * Spec: RTPO19, RTO24c1 + */ +internal class PathObjectSubscriptionRegister(private val bridge: ObjectsBridge) { + + private val tag = "PathObjectSubscriptionRegister" + + private data class SubscriptionEntry( + val listener: PathObjectListener, + val depth: Int?, // null = infinite depth (RTPO19c1) + val path: List, + ) + + private val subscriptions = ConcurrentHashMap() + private val nextSubscriptionId = AtomicLong(0) + + /** + * Registers a listener for updates at (and below, per the depth rule) the + * given path. Depth validity (RTPO19c1a) is enforced by the + * [PathObjectSubscriptionOptions] constructor and needs no re-check here. + * + * Spec: RTPO19 + */ + internal fun subscribe( + path: List, + listener: PathObjectListener, + options: PathObjectSubscriptionOptions?, + ): Subscription { + val id = nextSubscriptionId.incrementAndGet() + subscriptions[id] = SubscriptionEntry(listener, options?.depth, path) + return DefaultSubscription { subscriptions.remove(id) } // SUB2a + } + + /** + * Routes an applied object update to covered subscriptions. Candidate paths + * are priority-ordered: each full path to the updated object first, then - + * for map updates - the path to each updated key. The first candidate + * covered by a subscription determines the event's PathObject. + * + * Mirrors ably-js `liveobject.ts#_notifyPathSubscriptions` + + * `pathobjectsubscriptionregister.ts#notifyPathEvent`. + */ + internal fun notifyObjectUpdated(objectId: String, updatedKeys: Set, message: WireObjectMessage?) { + if (subscriptions.isEmpty()) return // fast path - path API unused on this channel + + val fullPaths = PathFinder.findFullPaths(bridge, objectId) + if (fullPaths.isEmpty()) return // object not reachable from root + + val candidatePaths = fullPaths.flatMap { fullPath -> + listOf(fullPath) + updatedKeys.map { key -> fullPath + key } + } + + val publicMessage = message?.let { + try { + it.toPublicMessage(bridge.channelName) + } catch (t: Throwable) { + Log.w(tag, "Failed to build public ObjectMessage for path event", t) + null + } + } + + for (entry in subscriptions.values) { + // first candidate covered by this subscription wins (priority order) + val coveredPath = candidatePaths.firstOrNull { coversPath(entry.path, entry.depth, it) } ?: continue + val event = DefaultPathObjectSubscriptionEvent( + DefaultPathObject(bridge, coveredPath), // RTPO19e1 + publicMessage, // RTPO19e2 + ) + try { + entry.listener.onUpdated(event) + } catch (t: Throwable) { + Log.e(tag, "Error in PathObjectListener callback", t) + } + } + } + + /** + * The subscription coverage rule: the event path must start with the + * subscription path and extend it by at most `depth - 1` further segments; + * null depth means no limit. + * + * Spec: RTO24c1 (worked examples in RTO24c2) + */ + internal fun coversPath(subscriptionPath: List, depth: Int?, eventPath: List): Boolean { + if (eventPath.size < subscriptionPath.size) return false + for (i in subscriptionPath.indices) { + if (subscriptionPath[i] != eventPath[i]) return false + } + if (depth == null) return true // infinite depth + val relativeDepth = eventPath.size - subscriptionPath.size + 1 + return relativeDepth <= depth + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/RootEntry.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/RootEntry.kt new file mode 100644 index 000000000..7f2acc676 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/RootEntry.kt @@ -0,0 +1,18 @@ +package io.ably.lib.`object` + +import io.ably.lib.`object`.path.DefaultLiveMapPathObject +import io.ably.lib.`object`.path.types.LiveMapPathObject + +/** + * The RTO23 entry point: returns the PathObject rooted at the channel's root + * InternalLiveMap with an empty path. In typed SDKs the static type is + * LiveMapPathObject (RTO23f / RTTS6d). Exposing this on the channel facade is + * the bridge's responsibility. + * + * Spec: RTO23 + */ +internal suspend fun ObjectsBridge.getRootPathObject(): LiveMapPathObject { + throwIfInvalidAccessApiConfiguration() // RTO23a / RTO25 + ensureAttachedAndSynced() // RTO23b, RTO23c, RTO23e + return DefaultLiveMapPathObject(this, emptyList()) // RTO23d, RTO23f +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/ValueConversion.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/ValueConversion.kt new file mode 100644 index 000000000..e40ecef08 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/ValueConversion.kt @@ -0,0 +1,104 @@ +package io.ably.lib.`object` + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import java.util.Base64 + +/** + * 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: MapNode) : ResolvedValue + data class CounterRef(val counter: CounterNode) : ResolvedValue + data class Leaf(val data: WireObjectData) : ResolvedValue +} + +/** + * Resolves entry data to its value: a node view when it references another + * object (null when the reference cannot be resolved), or a primitive leaf. + * + * Spec: RTLM5d2 (resolution semantics) + */ +internal fun WireObjectData.resolve(bridge: ObjectsBridge): ResolvedValue? { + objectId?.let { refId -> + return when (val refNode = bridge.getNode(refId)) { + is MapNode -> ResolvedValue.MapRef(refNode) + is CounterNode -> ResolvedValue.CounterRef(refNode) + else -> null + } + } + return ResolvedValue.Leaf(this) +} + +/** + * Maps a resolved value to the public ValueType enum. + * + * Spec: RTTS2a, RTTS4b3 + */ +internal fun ResolvedValue?.valueType(): ValueType = when (this) { + null -> ValueType.UNKNOWN + 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 + } +} + +/** Decodes the wire base64 binary leaf value, if present. */ +internal fun WireObjectData.decodedBytes(): ByteArray? = bytes?.let { Base64.getDecoder().decode(it) } + +/** + * Builds the compact JSON representation of a resolved value. + * + * - Map node: JSON object of its entries, recursively compacted; on + * revisiting an object (cyclic graph) a `{"objectId": ...}` stub is emitted + * instead of recursing. + * - Counter node: its numeric value. + * - Primitive leaves: the value itself; binary as a base64 string. + * + * Spec: RTPO14 / RTINS11 (mirrors ably-js livemap.ts#compactJson) + */ +internal fun ResolvedValue.compactJson(bridge: ObjectsBridge): JsonElement = when (this) { + is ResolvedValue.CounterRef -> JsonPrimitive(counter.count()) + is ResolvedValue.Leaf -> data.leafJson() + is ResolvedValue.MapRef -> map.compactJson(bridge, visited = mutableSetOf()) +} + +private fun MapNode.compactJson(bridge: ObjectsBridge, visited: MutableSet): JsonElement { + visited.add(objectId) + val result = JsonObject() + for ((key, data) in entries()) { + when (val resolved = data.resolve(bridge)) { + null -> Unit + is ResolvedValue.CounterRef -> result.add(key, JsonPrimitive(resolved.counter.count())) + is ResolvedValue.Leaf -> result.add(key, resolved.data.leafJson()) + is ResolvedValue.MapRef -> + if (resolved.map.objectId in visited) { + // cycle - emit a reference stub instead of recursing forever + result.add(key, JsonObject().apply { addProperty("objectId", resolved.map.objectId) }) + } else { + result.add(key, resolved.map.compactJson(bridge, visited)) + } + } + } + visited.remove(objectId) + return result +} + +private fun WireObjectData.leafJson(): JsonElement = when { + string != null -> JsonPrimitive(string) + number != null -> JsonPrimitive(number) + boolean != null -> JsonPrimitive(boolean) + bytes != null -> JsonPrimitive(bytes) // already base64; compact JSON carries binary as base64 string + json != null -> json!! + else -> JsonObject() +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/ValueTypeEvaluation.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/ValueTypeEvaluation.kt new file mode 100644 index 000000000..d9065683b --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/ValueTypeEvaluation.kt @@ -0,0 +1,146 @@ +package io.ably.lib.`object` + +import io.ably.lib.`object`.value.LiveMapValue +import java.util.Base64 + +/** + * Evaluation procedures for the LiveMap/LiveCounter value types: converts a + * creation-intent holder into the ordered `WireObjectMessage`s that create + * the described objects on the channel. + * + * Spec: RTLMV4, RTLCV4 + */ + +/** + * Evaluates this map value type into an ordered list of ObjectMessages. + * Messages for nested objects precede the final MAP_CREATE message for this + * map (RTLMV4d1, RTLMV4d2, RTLMV4k) — the last message in the list is always + * the MAP_CREATE whose objectId identifies the map this value represents. + * + * Spec: RTLMV4 + */ +internal suspend fun DefaultLiveMap.evaluate(bridge: ObjectsBridge): List { + val nestedMessages = mutableListOf() + val mapEntries = mutableMapOf() + + // RTLMV4b - keys must be valid + if (entries.keys.any { it.isEmpty() }) { + throw invalidInputError("Map keys must not be empty") // 400 / 40003 + } + + // RTLMV4d - build entries, recursively evaluating nested value types + for ((key, value) in entries) { + val data = objectDataFrom(value, nestedMessages, bridge) + mapEntries[key] = WireObjectsMapEntry(tombstone = false, data = data) + } + + // RTLMV4e - create the MapCreate object + val mapCreate = WireMapCreate(semantics = WireObjectsMapSemantics.LWW, entries = mapEntries) + + // RTLMV4f - initial value JSON string of the encoded MapCreate + val initialValueJSONString = wireGson.toJson(mapCreate) + + // RTLMV4g, RTLMV4h, RTLMV4i - nonce, server time, objectId + val nonce = generateObjectNonce() + val serverTime = bridge.getServerTime() + val objectId = ObjectsIdentifier.fromInitialValue(WireObjectType.Map, initialValueJSONString, nonce, serverTime) + + // RTLMV4j - the MAP_CREATE ObjectMessage; retain the MapCreate via derivedFrom (RTLMV4j5) + val mapCreateMessage = WireObjectMessage( + operation = WireObjectOperation( + action = WireObjectOperationAction.MapCreate, + objectId = objectId, + mapCreateWithObjectId = WireMapCreateWithObjectId( + nonce = nonce, + initialValue = initialValueJSONString, + derivedFrom = mapCreate, + ), + ) + ) + + // RTLMV4k - nested create messages first, this map's MAP_CREATE last + return nestedMessages + mapCreateMessage +} + +/** + * Evaluates this counter value type into a COUNTER_CREATE ObjectMessage. + * + * Spec: RTLCV4 + */ +internal suspend fun DefaultLiveCounter.evaluate(bridge: ObjectsBridge): WireObjectMessage { + // RTLCV4a - validate the count is a valid number + val countValue = count.toDouble() + if (countValue.isNaN() || countValue.isInfinite()) { + throw invalidInputError("Counter value must be a valid number") // 400 / 40003 + } + + // RTLCV4b - create the CounterCreate object + val counterCreate = WireCounterCreate(count = countValue) + + // RTLCV4c - initial value JSON string + val initialValueJSONString = wireGson.toJson(counterCreate) + + // RTLCV4d, RTLCV4e, RTLCV4f - nonce, server time, objectId + val nonce = generateObjectNonce() + val serverTime = bridge.getServerTime() + val objectId = ObjectsIdentifier.fromInitialValue(WireObjectType.Counter, initialValueJSONString, nonce, serverTime) + + // RTLCV4g - the COUNTER_CREATE ObjectMessage; retain the CounterCreate via derivedFrom (RTLCV4g5) + return WireObjectMessage( + operation = WireObjectOperation( + action = WireObjectOperationAction.CounterCreate, + objectId = objectId, + counterCreateWithObjectId = WireCounterCreateWithObjectId( + nonce = nonce, + initialValue = initialValueJSONString, + derivedFrom = counterCreate, + ), + ) + ) +} + +/** + * Converts a public LiveMapValue union member into wire ObjectData, + * recursively evaluating nested value types and collecting their create + * messages into [nestedMessages]. + * + * Spec: RTLMV4d1-RTLMV4d7 + */ +internal suspend fun objectDataFrom( + value: LiveMapValue, + nestedMessages: MutableList, + bridge: ObjectsBridge, +): WireObjectData = when { + // RTLMV4d1 - nested counter value type + value.isLiveCounter -> { + val counter = value.asLiveCounter as? DefaultLiveCounter + ?: throw invalidInputError("LiveCounter value type must be created via LiveCounter.create") + val message = counter.evaluate(bridge) + nestedMessages.add(message) + WireObjectData(objectId = message.operation!!.objectId) + } + // RTLMV4d2 - nested map value type; objectId comes from the final MAP_CREATE message + value.isLiveMap -> { + val map = value.asLiveMap as? DefaultLiveMap + ?: throw invalidInputError("LiveMap value type must be created via LiveMap.create") + val messages = map.evaluate(bridge) + nestedMessages.addAll(messages) + WireObjectData(objectId = messages.last().operation!!.objectId) + } + // RTLMV4d3 - JSON values + value.isJsonObject -> WireObjectData(json = value.asJsonObject) + value.isJsonArray -> WireObjectData(json = value.asJsonArray) + // RTLMV4d4 - string + value.isString -> WireObjectData(string = value.asString) + // RTLMV4d5 - number + value.isNumber -> WireObjectData(number = value.asNumber.toDouble()) + // RTLMV4d6 - boolean + value.isBoolean -> WireObjectData(boolean = value.asBoolean) + // RTLMV4d7 - binary (wire form is base64) + value.isBinary -> WireObjectData(bytes = Base64.getEncoder().encodeToString(value.asBinary)) + // RTLMV4c - unsupported data type + else -> throw objectsException( + "Unsupported data type for map value: ${value.javaClass.name}", + ObjectErrorCode.MapValueDataTypeUnsupported, // 40013 + ) +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/WireJson.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/WireJson.kt new file mode 100644 index 000000000..4e1e7a77c --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/WireJson.kt @@ -0,0 +1,42 @@ +package io.ably.lib.`object` + +import com.google.gson.GsonBuilder +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import java.lang.reflect.Type + +/** + * JSON serialization for the wire model, used to produce the initial-value + * JSON strings of MAP_CREATE / COUNTER_CREATE operations (RTLMV4f / RTLCV4c). + * + * Copied from the legacy serializers (`io.ably.lib.objects.serialization`) so + * the output is byte-identical to what the legacy create path produces — the + * initial-value string feeds the objectId hash (RTO14), so the format is a + * wire contract: enums serialize to their integer codes, ObjectData JSON + * leaves serialize as a JSON *string* (OD4c5), binary stays base64. + */ +internal val wireGson = GsonBuilder() + .registerTypeAdapter(WireObjectOperationAction::class.java, JsonSerializer { src, _, _ -> + JsonPrimitive(src.code) + }) + .registerTypeAdapter(WireObjectsMapSemantics::class.java, JsonSerializer { src, _, _ -> + JsonPrimitive(src.code) + }) + .registerTypeAdapter(WireObjectData::class.java, WireObjectDataJsonSerializer()) + .create() + +internal class WireObjectDataJsonSerializer : JsonSerializer { + override fun serialize(src: WireObjectData, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { + val obj = JsonObject() + src.objectId?.let { obj.addProperty("objectId", it) } + src.string?.let { obj.addProperty("string", it) } + src.number?.let { obj.addProperty("number", it) } + src.boolean?.let { obj.addProperty("boolean", it) } + src.bytes?.let { obj.addProperty("bytes", it) } + src.json?.let { obj.addProperty("json", it.toString()) } // Spec: OD4c5 + return obj + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/WireModel.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/WireModel.kt new file mode 100644 index 000000000..506364fc5 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/WireModel.kt @@ -0,0 +1,126 @@ +package io.ably.lib.`object` + +import com.google.gson.JsonElement +import com.google.gson.JsonObject + +/** + * 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* + */ + +/** 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: 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: 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 serial: String? = null, // OM2h + val serialTimestamp: Long? = null, // OM2j + val siteCode: String? = null, // OM2i +) diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultBaseInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultBaseInstance.kt new file mode 100644 index 000000000..fd84f9d0b --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultBaseInstance.kt @@ -0,0 +1,68 @@ +package io.ably.lib.`object`.instance + +import com.google.gson.JsonElement +import io.ably.lib.`object`.ObjectsBridge +import io.ably.lib.`object`.ResolvedValue +import io.ably.lib.`object`.ValueType +import io.ably.lib.`object`.compactJson +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.`object`.valueType + +/** + * Base implementation of the typed [Instance] hierarchy: wraps a value that + * was already resolved against the objects graph and implements everything + * the base class exposes per RTTS7 (compactJson, getType, the as* helpers). + * + * Spec: RTINS2, RTTS7, RTTS8, RTTS9 + */ +internal abstract class DefaultBaseInstance( + internal val bridge: ObjectsBridge, + internal val value: ResolvedValue, +) : Instance { + + /** Spec: RTTS8a - never UNKNOWN in normal operation (value resolved at construction) */ + override fun getType(): ValueType = value.valueType() + + /** Spec: RTINS11, RTINS11c (non-null), RTTS7a */ + override fun compactJson(): JsonElement { + bridge.throwIfInvalidAccessApiConfiguration() // RTINS11a / RTO25 + return value.compactJson(bridge) + } + + // RTTS9 - as* cast helpers: re-wrap without validation, never throw (RTTS9d) + override fun asLiveMap(): LiveMapInstance = DefaultLiveMapInstance(bridge, value) // RTTS9a + override fun asLiveCounter(): LiveCounterInstance = DefaultLiveCounterInstance(bridge, value) // RTTS9b + override fun asNumber(): NumberInstance = DefaultNumberInstance(bridge, value) // RTTS9c + override fun asString(): StringInstance = DefaultStringInstance(bridge, value) + override fun asBoolean(): BooleanInstance = DefaultBooleanInstance(bridge, value) + override fun asBinary(): BinaryInstance = DefaultBinaryInstance(bridge, value) + override fun asJsonObject(): JsonObjectInstance = DefaultJsonObjectInstance(bridge, value) + override fun asJsonArray(): JsonArrayInstance = DefaultJsonArrayInstance(bridge, value) + + internal companion object { + /** + * Wraps an already-resolved value in the Instance subclass matching its + * type (see e.g. RTPO8c, RTINS5c - an Instance is always constructed from + * a resolved value). + */ + internal fun from(bridge: ObjectsBridge, value: ResolvedValue): DefaultBaseInstance = when (value.valueType()) { + ValueType.LIVE_MAP -> DefaultLiveMapInstance(bridge, value) + ValueType.LIVE_COUNTER -> DefaultLiveCounterInstance(bridge, value) + ValueType.NUMBER -> DefaultNumberInstance(bridge, value) + ValueType.STRING -> DefaultStringInstance(bridge, value) + ValueType.BOOLEAN -> DefaultBooleanInstance(bridge, value) + ValueType.BINARY -> DefaultBinaryInstance(bridge, value) + ValueType.JSON_OBJECT -> DefaultJsonObjectInstance(bridge, value) + ValueType.JSON_ARRAY -> DefaultJsonArrayInstance(bridge, value) + // RTTS2a9 - cannot occur for a resolved value; fall back to a map view + ValueType.UNKNOWN -> DefaultLiveMapInstance(bridge, value) + } + } +} 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..bfeaa56dc --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultInstanceSubscriptionEvent.kt @@ -0,0 +1,18 @@ +package io.ably.lib.`object`.instance + +import io.ably.lib.`object`.message.ObjectMessage + +/** + * Event delivered to [InstanceListener]s. + * + * Spec: RTINS16e + */ +internal class DefaultInstanceSubscriptionEvent( + private val instance: Instance, + private val message: ObjectMessage?, +) : InstanceSubscriptionEvent { + + override fun getObject(): Instance = instance // RTINS16e1 + + override fun getMessage(): ObjectMessage? = message // RTINS16e2 +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultLiveCounterInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultLiveCounterInstance.kt new file mode 100644 index 000000000..b046bd83f --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultLiveCounterInstance.kt @@ -0,0 +1,82 @@ +package io.ably.lib.`object`.instance + +import io.ably.lib.`object`.CounterNode +import io.ably.lib.`object`.DefaultSubscription +import io.ably.lib.`object`.ObjectsBridge +import io.ably.lib.`object`.ResolvedValue +import io.ably.lib.`object`.Subscription +import io.ably.lib.`object`.WireCounterInc +import io.ably.lib.`object`.WireObjectMessage +import io.ably.lib.`object`.WireObjectOperation +import io.ably.lib.`object`.WireObjectOperationAction +import io.ably.lib.`object`.instance.types.LiveCounterInstance +import io.ably.lib.`object`.invalidInputError +import io.ably.lib.`object`.message.toPublicMessage +import io.ably.lib.`object`.typeMismatchError +import java.util.concurrent.CompletableFuture + +/** + * Typed Instance over an InternalLiveCounter. + * + * Spec: RTTS10b + */ +internal class DefaultLiveCounterInstance( + bridge: ObjectsBridge, + value: ResolvedValue, +) : DefaultBaseInstance(bridge, value), LiveCounterInstance { + + /** The wrapped counter node; mismatched as* wrappers fail per RTTS9d2 (92007). */ + private fun counterNodeOrThrow(): CounterNode = (value as? ResolvedValue.CounterRef)?.counter + ?: throw typeMismatchError("Instance does not wrap a LiveCounter") + + /** Spec: RTINS3a / RTTS10b - non-null (wrapped object always has an id) */ + override fun getId(): String = counterNodeOrThrow().objectId + + /** Spec: RTINS4 / RTTS10b - non-null Double */ + override fun value(): Double { + bridge.throwIfInvalidAccessApiConfiguration() // RTO25 + return counterNodeOrThrow().count() + } + + /** Spec: RTINS14 - amount defaults to 1 */ + override fun increment(): CompletableFuture = increment(1) + + /** Spec: RTINS14 */ + override fun increment(amount: Number): CompletableFuture = applyIncrement(amount.toDouble()) + + /** Spec: RTINS15 - amount defaults to 1 */ + override fun decrement(): CompletableFuture = decrement(1) + + /** Spec: RTINS15 */ + override fun decrement(amount: Number): CompletableFuture = applyIncrement(-amount.toDouble()) + + private fun applyIncrement(amount: Double): CompletableFuture = bridge.launchWithVoidFuture { + bridge.throwIfInvalidWriteApiConfiguration() // RTINS14b/RTINS15b / RTO26 + if (amount.isNaN() || amount.isInfinite()) { + throw invalidInputError("Counter amount must be a valid number") + } + val node = counterNodeOrThrow() // RTINS14d/RTINS15d - 92007 on mismatched wrapper + val message = WireObjectMessage( + operation = WireObjectOperation( + action = WireObjectOperationAction.CounterInc, + objectId = node.objectId, + counterInc = WireCounterInc(number = amount), + ) + ) + bridge.publish(listOf(message)) + } + + /** Spec: RTINS16 / RTTS10b - delivers both object and message (RTINS16e) */ + override fun subscribe(listener: InstanceListener): Subscription { + bridge.throwIfInvalidAccessApiConfiguration() + val node = counterNodeOrThrow() // RTINS16c + val unsubscribe = bridge.subscribeToUpdates(node.objectId) { _, wireMessage -> + val event = DefaultInstanceSubscriptionEvent( + this, // RTINS16e1 + wireMessage?.toPublicMessage(bridge.channelName), // RTINS16e2 + ) + listener.onUpdated(event) + } + return DefaultSubscription(unsubscribe) + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultLiveMapInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultLiveMapInstance.kt new file mode 100644 index 000000000..b43ea27dd --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultLiveMapInstance.kt @@ -0,0 +1,123 @@ +package io.ably.lib.`object`.instance + +import io.ably.lib.`object`.DefaultSubscription +import io.ably.lib.`object`.MapNode +import io.ably.lib.`object`.ObjectsBridge +import io.ably.lib.`object`.ResolvedValue +import io.ably.lib.`object`.Subscription +import io.ably.lib.`object`.WireMapRemove +import io.ably.lib.`object`.WireMapSet +import io.ably.lib.`object`.WireObjectMessage +import io.ably.lib.`object`.WireObjectOperation +import io.ably.lib.`object`.WireObjectOperationAction +import io.ably.lib.`object`.instance.types.LiveMapInstance +import io.ably.lib.`object`.invalidInputError +import io.ably.lib.`object`.message.toPublicMessage +import io.ably.lib.`object`.objectDataFrom +import io.ably.lib.`object`.resolve +import io.ably.lib.`object`.typeMismatchError +import io.ably.lib.`object`.value.LiveMapValue +import java.util.AbstractMap +import java.util.concurrent.CompletableFuture + +/** + * Typed Instance over an InternalLiveMap. + * + * Spec: RTTS10a + */ +internal class DefaultLiveMapInstance( + bridge: ObjectsBridge, + value: ResolvedValue, +) : DefaultBaseInstance(bridge, value), LiveMapInstance { + + /** The wrapped map node; mismatched as* wrappers fail per RTTS9d2 (92007). */ + private fun mapNodeOrThrow(): MapNode = (value as? ResolvedValue.MapRef)?.map + ?: throw typeMismatchError("Instance does not wrap a LiveMap") + + private fun mapNodeOrNull(): MapNode? = (value as? ResolvedValue.MapRef)?.map + + /** Spec: RTINS3a / RTTS10a - non-null (wrapped object always has an id) */ + override fun getId(): String = mapNodeOrThrow().objectId + + /** Spec: RTINS5 */ + override fun get(key: String): Instance? { + bridge.throwIfInvalidAccessApiConfiguration() // RTO25 + val node = mapNodeOrNull() ?: return null // RTTS9d1 / RTINS5d + val resolved = node.get(key)?.resolve(bridge) ?: return null + return from(bridge, resolved) + } + + /** Spec: RTINS6 */ + override fun entries(): Iterable> { + bridge.throwIfInvalidAccessApiConfiguration() // RTO25 + val node = mapNodeOrNull() ?: return emptyList() // RTTS9d1 / RTINS6c + return node.entries().mapNotNull { (key, data) -> + data.resolve(bridge)?.let { resolved -> + AbstractMap.SimpleImmutableEntry(key, from(bridge, resolved)) + } + } + } + + /** Spec: RTINS7 */ + override fun keys(): Iterable = entries().map { it.key } + + /** Spec: RTINS8 */ + override fun values(): Iterable = entries().map { it.value } + + /** Spec: RTINS9 / RTTS10a - non-null (the wrapped value is always a map) */ + override fun size(): Long { + bridge.throwIfInvalidAccessApiConfiguration() // RTO25 + return mapNodeOrThrow().entries().size.toLong() + } + + /** Spec: RTINS12 */ + override fun set(key: String, value: LiveMapValue): CompletableFuture = bridge.launchWithVoidFuture { + bridge.throwIfInvalidWriteApiConfiguration() // RTINS12b / RTO26 + if (key.isEmpty()) { + throw invalidInputError("Map key must not be empty") + } + val node = mapNodeOrThrow() // RTINS12d - 92007 on mismatched wrapper + // evaluate value-type arguments into create messages (RTLMV4/RTLCV4), publish together with the MAP_SET + val nestedMessages = mutableListOf() + val data = objectDataFrom(value, nestedMessages, bridge) + val mapSetMessage = WireObjectMessage( + operation = WireObjectOperation( + action = WireObjectOperationAction.MapSet, + objectId = node.objectId, + mapSet = WireMapSet(key = key, value = data), + ) + ) + bridge.publish(nestedMessages + mapSetMessage) + } + + /** Spec: RTINS13 */ + override fun remove(key: String): CompletableFuture = bridge.launchWithVoidFuture { + bridge.throwIfInvalidWriteApiConfiguration() // RTINS13b / RTO26 + if (key.isEmpty()) { + throw invalidInputError("Map key must not be empty") + } + val node = mapNodeOrThrow() // RTINS13d - 92007 on mismatched wrapper + val message = WireObjectMessage( + operation = WireObjectOperation( + action = WireObjectOperationAction.MapRemove, + objectId = node.objectId, + mapRemove = WireMapRemove(key = key), + ) + ) + bridge.publish(listOf(message)) + } + + /** Spec: RTINS16 / RTTS10a - delivers both object and message (RTINS16e) */ + override fun subscribe(listener: InstanceListener): Subscription { + bridge.throwIfInvalidAccessApiConfiguration() + val node = mapNodeOrThrow() // RTINS16c - subscribe is meaningful only on live objects + val unsubscribe = bridge.subscribeToUpdates(node.objectId) { _, wireMessage -> + val event = DefaultInstanceSubscriptionEvent( + this, // RTINS16e1 + wireMessage?.toPublicMessage(bridge.channelName), // RTINS16e2 + ) + listener.onUpdated(event) + } + return DefaultSubscription(unsubscribe) + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultPrimitiveInstances.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultPrimitiveInstances.kt new file mode 100644 index 000000000..1c5632c73 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultPrimitiveInstances.kt @@ -0,0 +1,79 @@ +package io.ably.lib.`object`.instance + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import io.ably.lib.`object`.ObjectsBridge +import io.ably.lib.`object`.ResolvedValue +import io.ably.lib.`object`.WireObjectData +import io.ably.lib.`object`.decodedBytes +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.NumberInstance +import io.ably.lib.`object`.instance.types.StringInstance +import io.ably.lib.`object`.typeMismatchError + +/** + * Shared base for the six primitive Instance subclasses (allowed by RTTS10d). + * Each adds only a non-null `value()` narrowed to its primitive (RTTS10c). + * + * On a mismatched as* wrapper the non-nullable read throws ErrorInfo 400/92007 + * (RTTS9d2-style; see the design note in DEFAULT_INTERFACE_IMPLEMENTATION.md - + * a Kotlin override of a `@NotNull` method cannot return null per RTTS9d1, and + * the typed contract makes the misuse statically visible, mirroring RTTS6e). + */ +internal abstract class DefaultPrimitiveInstance( + bridge: ObjectsBridge, + value: ResolvedValue, +) : DefaultBaseInstance(bridge, value) { + + /** The wrapped primitive leaf data; throws 92007 on mismatched wrappers. */ + protected fun leafOrThrow(expected: String): WireObjectData { + bridge.throwIfInvalidAccessApiConfiguration() // RTO25 + return (value as? ResolvedValue.Leaf)?.data + ?: throw typeMismatchError("Instance does not wrap a $expected value") + } +} + +/** Spec: RTTS10c */ +internal class DefaultNumberInstance(bridge: ObjectsBridge, value: ResolvedValue) : + DefaultPrimitiveInstance(bridge, value), NumberInstance { + override fun value(): Number = leafOrThrow("Number").number + ?: throw typeMismatchError("Instance does not wrap a Number value") +} + +/** Spec: RTTS10c */ +internal class DefaultStringInstance(bridge: ObjectsBridge, value: ResolvedValue) : + DefaultPrimitiveInstance(bridge, value), StringInstance { + override fun value(): String = leafOrThrow("String").string + ?: throw typeMismatchError("Instance does not wrap a String value") +} + +/** Spec: RTTS10c */ +internal class DefaultBooleanInstance(bridge: ObjectsBridge, value: ResolvedValue) : + DefaultPrimitiveInstance(bridge, value), BooleanInstance { + override fun value(): Boolean = leafOrThrow("Boolean").boolean + ?: throw typeMismatchError("Instance does not wrap a Boolean value") +} + +/** Spec: RTTS10c */ +internal class DefaultBinaryInstance(bridge: ObjectsBridge, value: ResolvedValue) : + DefaultPrimitiveInstance(bridge, value), BinaryInstance { + override fun value(): ByteArray = leafOrThrow("Binary").decodedBytes() + ?: throw typeMismatchError("Instance does not wrap a Binary value") +} + +/** Spec: RTTS10c */ +internal class DefaultJsonObjectInstance(bridge: ObjectsBridge, value: ResolvedValue) : + DefaultPrimitiveInstance(bridge, value), JsonObjectInstance { + override fun value(): JsonObject = leafOrThrow("JsonObject").json?.takeIf { it.isJsonObject }?.asJsonObject + ?: throw typeMismatchError("Instance does not wrap a JsonObject value") +} + +/** Spec: RTTS10c */ +internal class DefaultJsonArrayInstance(bridge: ObjectsBridge, value: ResolvedValue) : + DefaultPrimitiveInstance(bridge, value), JsonArrayInstance { + override fun value(): JsonArray = leafOrThrow("JsonArray").json?.takeIf { it.isJsonArray }?.asJsonArray + ?: throw typeMismatchError("Instance does not wrap a JsonArray value") +} 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..b84080e54 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/message/DefaultObjectMessage.kt @@ -0,0 +1,158 @@ +package io.ably.lib.`object`.message + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import io.ably.lib.`object`.WireCounterCreate +import io.ably.lib.`object`.WireCounterInc +import io.ably.lib.`object`.WireMapCreate +import io.ably.lib.`object`.WireMapRemove +import io.ably.lib.`object`.WireMapSet +import io.ably.lib.`object`.WireObjectData +import io.ably.lib.`object`.WireObjectMessage +import io.ably.lib.`object`.WireObjectOperation +import io.ably.lib.`object`.WireObjectOperationAction +import io.ably.lib.`object`.WireObjectsMapEntry +import io.ably.lib.`object`.WireObjectsMapSemantics +import io.ably.lib.`object`.decodedBytes +import io.ably.lib.`object`.objectStateError +import java.util.Collections + +/** + * 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.decodedBytes() + 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/path/DefaultBasePathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultBasePathObject.kt new file mode 100644 index 000000000..013bb2114 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultBasePathObject.kt @@ -0,0 +1,145 @@ +package io.ably.lib.`object`.path + +import com.google.gson.JsonElement +import io.ably.lib.`object`.MapNode +import io.ably.lib.`object`.ObjectsBridge +import io.ably.lib.`object`.ResolvedValue +import io.ably.lib.`object`.Subscription +import io.ably.lib.`object`.ValueType +import io.ably.lib.`object`.compactJson +import io.ably.lib.`object`.instance.DefaultBaseInstance +import io.ably.lib.`object`.instance.Instance +import io.ably.lib.`object`.path.types.BinaryPathObject +import io.ably.lib.`object`.path.types.BooleanPathObject +import io.ably.lib.`object`.path.types.JsonArrayPathObject +import io.ably.lib.`object`.path.types.JsonObjectPathObject +import io.ably.lib.`object`.path.types.LiveCounterPathObject +import io.ably.lib.`object`.path.types.LiveMapPathObject +import io.ably.lib.`object`.resolve +import io.ably.lib.`object`.path.types.NumberPathObject +import io.ably.lib.`object`.path.types.StringPathObject +import io.ably.lib.`object`.valueType + +/** + * Base implementation of the typed [PathObject] hierarchy: a cheap + * navigational handle holding the path segments (RTPO2a); the underlying + * value is resolved against the objects graph on every call (RTPO3), exactly + * like ably-js `pathobject.ts`. + * + * Spec: RTPO1, RTPO2, RTPO3, RTTS3, RTTS4, RTTS5 + */ +internal abstract class DefaultBasePathObject( + internal val bridge: ObjectsBridge, + internal val pathSegments: List, +) : PathObject { + + /** + * The path resolution procedure: walks the stored segments from the root + * map. Returns null on any resolution failure (RTPO3c). + * + * Spec: RTPO3 + */ + internal fun resolve(): ResolvedValue? { + val root = bridge.getRootNode() ?: return null + var current: ResolvedValue = ResolvedValue.MapRef(root) + for (segment in pathSegments) { + val map = (current as? ResolvedValue.MapRef)?.map ?: return null // RTPO3b - intermediate must be a map + current = map.get(segment)?.resolve(bridge) ?: return null // RTPO3c - missing/unresolvable entry + } + return current + } + + /** Spec: RTPO4 - dot-delimited; dots inside segments escaped as `\.` (RTPO4b) */ + override fun path(): String = pathSegments.joinToString(".") { it.replace(".", "\\.") } + + /** Spec: RTPO8, RTTS3b - wraps live objects only; null for primitives/unresolved (RTPO8d) */ + override fun instance(): Instance? { + bridge.throwIfInvalidAccessApiConfiguration() // RTPO8a / RTO25 + return when (val resolved = resolve()) { + is ResolvedValue.MapRef, is ResolvedValue.CounterRef -> DefaultBaseInstance.from(bridge, resolved) + else -> null + } + } + + /** Spec: RTPO14, RTTS3c - null when the path does not resolve (RTPO3c1) */ + override fun compactJson(): JsonElement? { + bridge.throwIfInvalidAccessApiConfiguration() // RTPO14a / RTO25 + return resolve()?.compactJson(bridge) + } + + /** Spec: RTTS4a - best-effort existence check at call time */ + override fun exists(): Boolean { + bridge.throwIfInvalidAccessApiConfiguration() // RTTS4a1 / RTO25 + return resolve() != null // RTTS4a2, RTTS4a3 + } + + /** Spec: RTTS4b - UNKNOWN when resolution fails (RTTS4b3) */ + override fun getType(): ValueType { + bridge.throwIfInvalidAccessApiConfiguration() // RTTS4b1 / RTO25 + return resolve().valueType() // RTTS4b2, RTTS4b3 + } + + // RTTS5 - as* cast helpers: re-wrap sharing path and root, never throw (RTTS5d) + override fun asLiveMap(): LiveMapPathObject = DefaultLiveMapPathObject(bridge, pathSegments) // RTTS5a + override fun asLiveCounter(): LiveCounterPathObject = DefaultLiveCounterPathObject(bridge, pathSegments) // RTTS5b + override fun asNumber(): NumberPathObject = DefaultNumberPathObject(bridge, pathSegments) // RTTS5c + override fun asString(): StringPathObject = DefaultStringPathObject(bridge, pathSegments) + override fun asBoolean(): BooleanPathObject = DefaultBooleanPathObject(bridge, pathSegments) + override fun asBinary(): BinaryPathObject = DefaultBinaryPathObject(bridge, pathSegments) + override fun asJsonObject(): JsonObjectPathObject = DefaultJsonObjectPathObject(bridge, pathSegments) + override fun asJsonArray(): JsonArrayPathObject = DefaultJsonArrayPathObject(bridge, pathSegments) + + /** Spec: RTPO19, RTTS3d */ + override fun subscribe(listener: PathObjectListener): Subscription = + subscribe(listener, null) + + /** Spec: RTPO19, RTTS3d - depth validation (RTPO19c1a) is done by the options constructor */ + override fun subscribe(listener: PathObjectListener, options: PathObjectSubscriptionOptions?): Subscription { + bridge.throwIfInvalidAccessApiConfiguration() // RTPO19a / RTO25 + return bridge.pathSubscriptionRegister.subscribe(pathSegments, listener, options) + } + + internal companion object { + /** + * Parses a dot-delimited path string into segments; a backslash-escaped + * dot (`\.`) is a literal dot within a segment. + * + * Spec: RTPO6 (and the inverse of RTPO4b) + */ + internal fun parsePath(path: String): List { + val segments = mutableListOf() + val current = StringBuilder() + var i = 0 + while (i < path.length) { + val c = path[i] + when { + c == '\\' && i + 1 < path.length && path[i + 1] == '.' -> { + current.append('.') + i += 2 + } + c == '.' -> { + segments.add(current.toString()) + current.setLength(0) + i++ + } + else -> { + current.append(c) + i++ + } + } + } + segments.add(current.toString()) + return segments + } + } +} + +/** + * Concrete untyped PathObject - returned wherever the static type is the base + * [PathObject] (get/at results, subscription event objects); callers narrow + * via the as* helpers (RTTS3g). + */ +internal class DefaultPathObject( + bridge: ObjectsBridge, + pathSegments: List, +) : DefaultBasePathObject(bridge, pathSegments) diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultLiveCounterPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultLiveCounterPathObject.kt new file mode 100644 index 000000000..c1dc6f28d --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultLiveCounterPathObject.kt @@ -0,0 +1,66 @@ +package io.ably.lib.`object`.path + +import io.ably.lib.`object`.CounterNode +import io.ably.lib.`object`.ObjectsBridge +import io.ably.lib.`object`.ResolvedValue +import io.ably.lib.`object`.WireCounterInc +import io.ably.lib.`object`.WireObjectMessage +import io.ably.lib.`object`.WireObjectOperation +import io.ably.lib.`object`.WireObjectOperationAction +import io.ably.lib.`object`.invalidInputError +import io.ably.lib.`object`.path.types.LiveCounterPathObject +import io.ably.lib.`object`.pathNotResolvedError +import io.ably.lib.`object`.typeMismatchError +import java.util.concurrent.CompletableFuture + +/** + * Typed PathObject expected to resolve to an InternalLiveCounter. + * + * Spec: RTTS6b + */ +internal class DefaultLiveCounterPathObject( + bridge: ObjectsBridge, + pathSegments: List, +) : DefaultBasePathObject(bridge, pathSegments), LiveCounterPathObject { + + /** Resolves and requires a counter for write operations: 92005 / 92007 per RTPO3c2 / RTTS5d2. */ + private fun counterNodeForWrite(): CounterNode = when (val resolved = resolve()) { + null -> throw pathNotResolvedError(path()) // RTPO3c2 - 92005 + is ResolvedValue.CounterRef -> resolved.counter + else -> throw typeMismatchError("Value at path \"${path()}\" is not a LiveCounter") // RTTS5d2 - 92007 + } + + /** Spec: RTPO7 / RTTS6b - null when the path does not resolve to a counter (RTPO3c1, RTPO7e) */ + override fun value(): Double? { + bridge.throwIfInvalidAccessApiConfiguration() // RTPO7a / RTO25 + return (resolve() as? ResolvedValue.CounterRef)?.counter?.count() + } + + /** Spec: RTPO17 - amount defaults to 1 */ + override fun increment(): CompletableFuture = increment(1) + + /** Spec: RTPO17 */ + override fun increment(amount: Number): CompletableFuture = applyIncrement(amount.toDouble()) + + /** Spec: RTPO18 - amount defaults to 1 */ + override fun decrement(): CompletableFuture = decrement(1) + + /** Spec: RTPO18 */ + override fun decrement(amount: Number): CompletableFuture = applyIncrement(-amount.toDouble()) + + private fun applyIncrement(amount: Double): CompletableFuture = bridge.launchWithVoidFuture { + bridge.throwIfInvalidWriteApiConfiguration() // RTPO17a/RTPO18a / RTO26 + if (amount.isNaN() || amount.isInfinite()) { + throw invalidInputError("Counter amount must be a valid number") + } + val node = counterNodeForWrite() + val message = WireObjectMessage( + operation = WireObjectOperation( + action = WireObjectOperationAction.CounterInc, + objectId = node.objectId, + counterInc = WireCounterInc(number = amount), + ) + ) + bridge.publish(listOf(message)) + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultLiveMapPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultLiveMapPathObject.kt new file mode 100644 index 000000000..4cfd2a60b --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultLiveMapPathObject.kt @@ -0,0 +1,107 @@ +package io.ably.lib.`object`.path + +import io.ably.lib.`object`.MapNode +import io.ably.lib.`object`.ObjectsBridge +import io.ably.lib.`object`.ResolvedValue +import io.ably.lib.`object`.WireMapRemove +import io.ably.lib.`object`.WireMapSet +import io.ably.lib.`object`.WireObjectMessage +import io.ably.lib.`object`.WireObjectOperation +import io.ably.lib.`object`.WireObjectOperationAction +import io.ably.lib.`object`.invalidInputError +import io.ably.lib.`object`.objectDataFrom +import io.ably.lib.`object`.path.types.LiveMapPathObject +import io.ably.lib.`object`.pathNotResolvedError +import io.ably.lib.`object`.typeMismatchError +import io.ably.lib.`object`.value.LiveMapValue +import java.util.AbstractMap +import java.util.concurrent.CompletableFuture + +/** + * Typed PathObject expected to resolve to an InternalLiveMap. + * + * Spec: RTTS6a + */ +internal class DefaultLiveMapPathObject( + bridge: ObjectsBridge, + pathSegments: List, +) : DefaultBasePathObject(bridge, pathSegments), LiveMapPathObject { + + /** Resolves and requires a map for write operations: 92005 when the path does not resolve (RTPO3c2), 92007 on type mismatch (RTTS5d2). */ + private fun mapNodeForWrite(): MapNode = when (val resolved = resolve()) { + null -> throw pathNotResolvedError(path()) // RTPO3c2 - 92005 + is ResolvedValue.MapRef -> resolved.map + else -> throw typeMismatchError("Value at path \"${path()}\" is not a LiveMap") // RTTS5d2 - 92007 + } + + private fun mapNodeOrNull(): MapNode? = (resolve() as? ResolvedValue.MapRef)?.map + + /** Spec: RTPO5 - purely navigational, no resolution performed */ + override fun get(key: String): PathObject = DefaultPathObject(bridge, pathSegments + key) + + /** Spec: RTPO6 - purely navigational; dot-delimited with `\.` escapes */ + override fun at(path: String): PathObject = + DefaultPathObject(bridge, pathSegments + parsePath(path)) + + /** Spec: RTPO9 - empty when the path does not resolve to a map (RTPO3c1) */ + override fun entries(): Iterable> { + bridge.throwIfInvalidAccessApiConfiguration() // RTPO9a / RTO25 + val node = mapNodeOrNull() ?: return emptyList() + return node.entries().keys.map { key -> + AbstractMap.SimpleImmutableEntry(key, get(key)) // child paths as if by get(key) + } + } + + /** Spec: RTPO10 */ + override fun keys(): Iterable { + bridge.throwIfInvalidAccessApiConfiguration() // RTPO10a / RTO25 + val node = mapNodeOrNull() ?: return emptyList() + return node.entries().keys.toList() + } + + /** Spec: RTPO11 */ + override fun values(): Iterable = entries().map { it.value } + + /** Spec: RTPO12 - null when the path does not resolve to a map */ + override fun size(): Long? { + bridge.throwIfInvalidAccessApiConfiguration() // RTPO12a / RTO25 + return mapNodeOrNull()?.entries()?.size?.toLong() + } + + /** Spec: RTPO15 */ + override fun set(key: String, value: LiveMapValue): CompletableFuture = bridge.launchWithVoidFuture { + bridge.throwIfInvalidWriteApiConfiguration() // RTPO15a / RTO26 + if (key.isEmpty()) { + throw invalidInputError("Map key must not be empty") + } + val node = mapNodeForWrite() + // evaluate value-type arguments into create messages (RTLMV4/RTLCV4), publish together with the MAP_SET + val nestedMessages = mutableListOf() + val data = objectDataFrom(value, nestedMessages, bridge) + val mapSetMessage = WireObjectMessage( + operation = WireObjectOperation( + action = WireObjectOperationAction.MapSet, + objectId = node.objectId, + mapSet = WireMapSet(key = key, value = data), + ) + ) + bridge.publish(nestedMessages + mapSetMessage) + } + + /** Spec: RTPO16 */ + override fun remove(key: String): CompletableFuture = bridge.launchWithVoidFuture { + bridge.throwIfInvalidWriteApiConfiguration() // RTPO16a / RTO26 + if (key.isEmpty()) { + throw invalidInputError("Map key must not be empty") + } + val node = mapNodeForWrite() + val message = WireObjectMessage( + operation = WireObjectOperation( + action = WireObjectOperationAction.MapRemove, + objectId = node.objectId, + mapRemove = WireMapRemove(key = key), + ) + ) + bridge.publish(listOf(message)) + } +} 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..489cff734 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPathObjectSubscriptionEvent.kt @@ -0,0 +1,18 @@ +package io.ably.lib.`object`.path + +import io.ably.lib.`object`.message.ObjectMessage + +/** + * Event delivered to [PathObjectListener]s. + * + * Spec: RTPO19e + */ +internal class DefaultPathObjectSubscriptionEvent( + private val pathObject: PathObject, + private val message: ObjectMessage?, +) : PathObjectSubscriptionEvent { + + override fun getObject(): PathObject = pathObject // RTPO19e1 + + override fun getMessage(): ObjectMessage? = message // RTPO19e2 +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPrimitivePathObjects.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPrimitivePathObjects.kt new file mode 100644 index 000000000..d8877d23e --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultPrimitivePathObjects.kt @@ -0,0 +1,68 @@ +package io.ably.lib.`object`.path + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import io.ably.lib.`object`.ObjectsBridge +import io.ably.lib.`object`.ResolvedValue +import io.ably.lib.`object`.WireObjectData +import io.ably.lib.`object`.decodedBytes +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.NumberPathObject +import io.ably.lib.`object`.path.types.StringPathObject + +/** + * Shared base for the six primitive PathObject subclasses (allowed by + * RTTS6f). Each adds only a nullable `value()` narrowed to its primitive, + * returning null when the path does not resolve or resolves to a different + * type (RTPO3c1, RTTS6c). + */ +internal abstract class DefaultPrimitivePathObject( + bridge: ObjectsBridge, + pathSegments: List, +) : DefaultBasePathObject(bridge, pathSegments) { + + /** The resolved primitive leaf data, or null (RTPO3c1). */ + protected fun resolvedLeaf(): WireObjectData? { + bridge.throwIfInvalidAccessApiConfiguration() // RTPO7a / RTO25 + return (resolve() as? ResolvedValue.Leaf)?.data + } +} + +/** Spec: RTTS6c */ +internal class DefaultNumberPathObject(bridge: ObjectsBridge, pathSegments: List) : + DefaultPrimitivePathObject(bridge, pathSegments), NumberPathObject { + override fun value(): Number? = resolvedLeaf()?.number +} + +/** Spec: RTTS6c */ +internal class DefaultStringPathObject(bridge: ObjectsBridge, pathSegments: List) : + DefaultPrimitivePathObject(bridge, pathSegments), StringPathObject { + override fun value(): String? = resolvedLeaf()?.string +} + +/** Spec: RTTS6c */ +internal class DefaultBooleanPathObject(bridge: ObjectsBridge, pathSegments: List) : + DefaultPrimitivePathObject(bridge, pathSegments), BooleanPathObject { + override fun value(): Boolean? = resolvedLeaf()?.boolean +} + +/** Spec: RTTS6c */ +internal class DefaultBinaryPathObject(bridge: ObjectsBridge, pathSegments: List) : + DefaultPrimitivePathObject(bridge, pathSegments), BinaryPathObject { + override fun value(): ByteArray? = resolvedLeaf()?.decodedBytes() +} + +/** Spec: RTTS6c */ +internal class DefaultJsonObjectPathObject(bridge: ObjectsBridge, pathSegments: List) : + DefaultPrimitivePathObject(bridge, pathSegments), JsonObjectPathObject { + override fun value(): JsonObject? = resolvedLeaf()?.json?.takeIf { it.isJsonObject }?.asJsonObject +} + +/** Spec: RTTS6c */ +internal class DefaultJsonArrayPathObject(bridge: ObjectsBridge, pathSegments: List) : + DefaultPrimitivePathObject(bridge, pathSegments), JsonArrayPathObject { + override fun value(): JsonArray? = resolvedLeaf()?.json?.takeIf { it.isJsonArray }?.asJsonArray +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/Fakes.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/Fakes.kt new file mode 100644 index 000000000..ddc81eb04 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/Fakes.kt @@ -0,0 +1,58 @@ +package io.ably.lib.`object`.unit + +import io.ably.lib.`object`.CounterNode +import io.ably.lib.`object`.MapNode +import io.ably.lib.`object`.ObjectsBridge +import io.ably.lib.`object`.ObjectsNode +import io.ably.lib.`object`.WireObjectData +import io.ably.lib.`object`.WireObjectMessage + +/** + * Minimal in-memory ObjectsBridge for unit tests; also documents the contract + * a real bridge implementation must provide. + */ +internal class FakeBridge : ObjectsBridge() { + + internal val nodes = mutableMapOf() + internal val published = mutableListOf() + internal var rootId: String? = "root" + + internal fun addMap(objectId: String, entries: MutableMap = mutableMapOf()): FakeMapNode = + FakeMapNode(objectId, entries).also { nodes[objectId] = it } + + internal fun addCounter(objectId: String, count: Double): FakeCounterNode = + FakeCounterNode(objectId, count).also { nodes[objectId] = it } + + override val channelName: String = "test-channel" + + override fun getRootNode(): MapNode? = rootId?.let { nodes[it] as? MapNode } + + override fun getNode(objectId: String): ObjectsNode? = nodes[objectId] + + override fun throwIfInvalidAccessApiConfiguration() = Unit + + override fun throwIfInvalidWriteApiConfiguration() = Unit + + override suspend fun publish(messages: List) { + published.addAll(messages) + } + + override suspend fun getServerTime(): Long = 1_718_000_000_000L + + override suspend fun ensureAttachedAndSynced() = Unit +} + +internal class FakeMapNode( + override val objectId: String, + internal val data: MutableMap = mutableMapOf(), +) : MapNode { + override fun entries(): Map = data.toMap() + override fun get(key: String): WireObjectData? = data[key] +} + +internal class FakeCounterNode( + override val objectId: String, + internal var value: Double, +) : CounterNode { + override fun count(): Double = value +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/PathApiTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/PathApiTest.kt new file mode 100644 index 000000000..abeeb5cf2 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/PathApiTest.kt @@ -0,0 +1,204 @@ +package io.ably.lib.`object`.unit + +import io.ably.lib.`object`.PathFinder +import io.ably.lib.`object`.ValueType +import io.ably.lib.`object`.WireMapSet +import io.ably.lib.`object`.WireObjectData +import io.ably.lib.`object`.WireObjectMessage +import io.ably.lib.`object`.WireObjectOperation +import io.ably.lib.`object`.WireObjectOperationAction +import io.ably.lib.`object`.path.DefaultBasePathObject +import io.ably.lib.`object`.path.DefaultLiveMapPathObject +import io.ably.lib.`object`.path.DefaultPathObject +import io.ably.lib.`object`.path.PathObjectSubscriptionEvent +import io.ably.lib.`object`.value.LiveMapValue +import io.ably.lib.types.AblyException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Covers path parsing/escaping (RTPO4/RTPO6), path resolution (RTPO3), typed + * reads/writes, the depth coverage rule (RTO24c1/RTO24c2), PathFinder + * (RTLO4f-equivalent) and subscription event delivery (RTPO19/RTINS16). + */ +internal class PathApiTest { + + /** root { profile: { name: "sachin", score: counter(5) }, flag: true } */ + private fun graphBridge(): FakeBridge { + val bridge = FakeBridge() + val root = bridge.addMap("root") + val profile = bridge.addMap("map:profile@1") + bridge.addCounter("counter:score@1", 5.0) + root.data["profile"] = WireObjectData(objectId = "map:profile@1") + root.data["flag"] = WireObjectData(boolean = true) + profile.data["name"] = WireObjectData(string = "sachin") + profile.data["score"] = WireObjectData(objectId = "counter:score@1") + return bridge + } + + @Test + fun `path escaping and parsing round-trip`() { + val bridge = graphBridge() + // RTPO4b - dots inside segments are escaped + assertEquals("a\\.b.c", DefaultPathObject(bridge, listOf("a.b", "c")).path()) + // RTPO6 - parsing honours the escape + assertEquals(listOf("a.b", "c"), DefaultBasePathObject.parsePath("a\\.b.c")) + assertEquals(listOf("users", "emma"), DefaultBasePathObject.parsePath("users.emma")) + } + + @Test + fun `resolution and typed reads`() { + val bridge = graphBridge() + val root = DefaultLiveMapPathObject(bridge, emptyList()) + + assertEquals("sachin", root.at("profile.name").asString().value()) // RTPO7 + assertEquals(5.0, root.at("profile.score").asLiveCounter().value()) // RTTS6b + assertEquals(true, root.at("flag").asBoolean().value()) + assertNull(root.at("missing.path").asString().value()) // RTPO3c1 + + assertEquals(ValueType.LIVE_MAP, root.get("profile").getType()) // RTTS4b + assertEquals(ValueType.UNKNOWN, root.get("missing").getType()) // RTTS4b3 + assertTrue(root.get("profile").exists()) // RTTS4a + assertFalse(root.get("missing").exists()) + + assertEquals(setOf("profile", "flag"), root.keys().toSet()) // RTPO10 + assertEquals(2L, root.size()) // RTPO12 + assertNull(root.at("profile.name").asLiveMap().size()) // size on non-map -> null + + // RTPO8 - instance() wraps live objects, null for primitives + assertNotNull(root.get("profile").instance()) + assertNull(root.at("profile.name").instance()) + + // RTPO14 - compactJson + val json = root.compactJson()!!.asJsonObject + assertEquals("sachin", json.getAsJsonObject("profile").get("name").asString) + assertEquals(5.0, json.getAsJsonObject("profile").get("score").asDouble) + } + + @Test + fun `writes resolve the target and publish through the bridge`() { + val bridge = graphBridge() + val profile = DefaultLiveMapPathObject(bridge, emptyList()).get("profile").asLiveMap() + + profile.set("city", LiveMapValue.of("pune")).get() // RTPO15 + val mapSet = bridge.published.single().operation!! + assertEquals(WireObjectOperationAction.MapSet, mapSet.action) + assertEquals("map:profile@1", mapSet.objectId) + assertEquals("pune", mapSet.mapSet!!.value.string) + + // RTPO3c2 - write on an unresolvable path fails with 92005 + val unresolved = DefaultLiveMapPathObject(bridge, listOf("missing")) + val resolutionFailure = assertFailsWithAblyCode(92005) { unresolved.set("k", LiveMapValue.of(1)).get() } + assertEquals(400, resolutionFailure.errorInfo.statusCode) + + // RTTS5d2 - write on a mismatched type fails with 92007 + val mismatch = DefaultLiveMapPathObject(bridge, listOf("flag")) + assertFailsWithAblyCode(92007) { mismatch.remove("k").get() } + } + + @Test + fun `coverage rule follows RTO24c2 worked examples`() { + val bridge = graphBridge() + val register = bridge.pathSubscriptionRegister + val sub = listOf("users") + + // depth=null: covers any depth + assertTrue(register.coversPath(sub, null, listOf("users"))) + assertTrue(register.coversPath(sub, null, listOf("users", "emma", "visits"))) + // depth=1: covers ["users"] only + assertTrue(register.coversPath(sub, 1, listOf("users"))) + assertFalse(register.coversPath(sub, 1, listOf("users", "emma"))) + // depth=2: covers ["users"], ["users","emma"] only + assertTrue(register.coversPath(sub, 2, listOf("users", "emma"))) + assertFalse(register.coversPath(sub, 2, listOf("users", "emma", "visits"))) + // depth=3: covers up to ["users","emma","visits"] + assertTrue(register.coversPath(sub, 3, listOf("users", "emma", "visits"))) + // non-matching prefix / shorter event path + assertFalse(register.coversPath(sub, null, listOf("admins", "emma"))) + assertFalse(register.coversPath(listOf("users", "emma"), null, listOf("users"))) + } + + @Test + fun `PathFinder returns every path to the target`() { + val bridge = graphBridge() + // add a second reference to the same counter + (bridge.nodes["root"] as FakeMapNode).data["topScore"] = WireObjectData(objectId = "counter:score@1") + + val paths = PathFinder.findFullPaths(bridge, "counter:score@1") + assertEquals(setOf(listOf("profile", "score"), listOf("topScore")), paths.toSet()) + assertEquals(listOf(emptyList()), PathFinder.findFullPaths(bridge, "root")) + assertTrue(PathFinder.findFullPaths(bridge, "counter:unreachable@1").isEmpty()) + } + + @Test + fun `path subscriptions deliver covered events with the public message`() { + val bridge = graphBridge() + val root = DefaultLiveMapPathObject(bridge, emptyList()) + val events = mutableListOf() + val subscription = root.get("profile").subscribe { events.add(it) } // RTPO19 + + val wireMessage = WireObjectMessage( + serial = "01-ab@cde-0", + operation = WireObjectOperation( + action = WireObjectOperationAction.MapSet, + objectId = "map:profile@1", + mapSet = WireMapSet("name", WireObjectData(string = "sachin")), + ), + ) + bridge.notifyUpdated("map:profile@1", setOf("name"), wireMessage) + + val event = events.single() + assertEquals("profile", event.getObject().path()) // RTPO19e1 - first covered candidate path + val publicMessage = event.getMessage()!! // RTPO19e2 + assertEquals("test-channel", publicMessage.channel) // PAOM2e + assertEquals("01-ab@cde-0", publicMessage.serial) + assertEquals("name", publicMessage.operation.mapSet!!.key) + + // SUB2a - unsubscribe stops delivery + subscription.unsubscribe() + bridge.notifyUpdated("map:profile@1", setOf("name"), wireMessage) + assertEquals(1, events.size) + } + + @Test + fun `instance subscriptions deliver events with the public message`() { + val bridge = graphBridge() + val instance = DefaultLiveMapPathObject(bridge, emptyList()).get("profile").instance()!!.asLiveMap() + var received = 0 + instance.subscribe { event -> // RTINS16 + received++ + assertNotNull(event.getObject()) // RTINS16e1 + assertEquals(WireObjectOperationAction.MapSet.let { "MAP_SET" }, event.getMessage()!!.operation.action.name) + } + bridge.notifyUpdated( + "map:profile@1", + setOf("name"), + WireObjectMessage( + operation = WireObjectOperation( + action = WireObjectOperationAction.MapSet, + objectId = "map:profile@1", + mapSet = WireMapSet("name", WireObjectData(string = "x")), + ) + ), + ) + assertEquals(1, received) + } + + private fun assertFailsWithAblyCode(code: Int, block: () -> Unit): AblyException { + try { + block() + } catch (e: Exception) { + val ably = (e as? AblyException) ?: (e.cause as? AblyException) + if (ably != null) { + assertEquals(code, ably.errorInfo.code) + return ably + } + throw AssertionError("Expected AblyException with code $code, got $e", e) + } + throw AssertionError("Expected AblyException with code $code, but nothing was thrown") + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/ValueTypeContractTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/ValueTypeContractTest.kt new file mode 100644 index 000000000..a3ce64182 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/ValueTypeContractTest.kt @@ -0,0 +1,95 @@ +package io.ably.lib.`object`.unit + +import io.ably.lib.`object`.DefaultLiveCounter +import io.ably.lib.`object`.DefaultLiveMap +import io.ably.lib.`object`.WireObjectOperationAction +import io.ably.lib.`object`.evaluate +import io.ably.lib.`object`.value.LiveCounter +import io.ably.lib.`object`.value.LiveMap +import io.ably.lib.`object`.value.LiveMapValue +import io.ably.lib.types.AblyException +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Verifies the frozen reflective contract between the lib `LiveMap`/`LiveCounter` + * value types and this package (RTLMV3/RTLCV3), and the evaluation procedures + * (RTLMV4/RTLCV4). + */ +internal class ValueTypeContractTest { + + @Test + fun `lib LiveCounter create reflectively instantiates DefaultLiveCounter`() { + // exercises lib's Class.forName("io.ably.lib.object.DefaultLiveCounter") path + val counter = LiveCounter.create(5) + val impl = assertIs(counter) + assertEquals(5, impl.count) // RTLCV3b + + val zero = assertIs(LiveCounter.create()) + assertEquals(0, zero.count) // RTLCV3a1 - defaults to 0 + } + + @Test + fun `lib LiveMap create reflectively instantiates DefaultLiveMap`() { + // exercises lib's Class.forName("io.ably.lib.object.DefaultLiveMap") path + val map = LiveMap.create(mapOf("name" to LiveMapValue.of("sachin"))) + val impl = assertIs(map) + assertEquals(setOf("name"), impl.entries.keys) // RTLMV3b + + val empty = assertIs(LiveMap.create()) + assertTrue(empty.entries.isEmpty()) + } + + @Test + fun `counter evaluation produces COUNTER_CREATE message`(): Unit = runBlocking { + val bridge = FakeBridge() + val counter = LiveCounter.create(7) as DefaultLiveCounter + + val message = counter.evaluate(bridge) // RTLCV4 + + val operation = message.operation!! + assertEquals(WireObjectOperationAction.CounterCreate, operation.action) // RTLCV4g1 + assertTrue(operation.objectId.startsWith("counter:")) // RTO14 + val create = operation.counterCreateWithObjectId!! + assertEquals(16, create.nonce.length) // RTLCV4d + assertEquals(7.0, create.derivedFrom!!.count) // RTLCV4g5 + assertTrue(create.initialValue.contains("7.0")) // RTLCV4c + } + + @Test + fun `map evaluation orders nested creates before the parent MAP_CREATE`(): Unit = runBlocking { + val bridge = FakeBridge() + val map = LiveMap.create( + mapOf( + "score" to LiveMapValue.of(LiveCounter.create(1)), + "title" to LiveMapValue.of("hello"), + ) + ) as DefaultLiveMap + + val messages = map.evaluate(bridge) // RTLMV4 + + assertEquals(2, messages.size) + // RTLMV4k - nested COUNTER_CREATE first, the map's own MAP_CREATE last + assertEquals(WireObjectOperationAction.CounterCreate, messages[0].operation!!.action) + val mapCreateOperation = messages[1].operation!! + assertEquals(WireObjectOperationAction.MapCreate, mapCreateOperation.action) + assertTrue(mapCreateOperation.objectId.startsWith("map:")) + // RTLMV4d1 - the entry references the nested counter's objectId + val entries = mapCreateOperation.mapCreateWithObjectId!!.derivedFrom!!.entries + assertEquals(messages[0].operation!!.objectId, entries["score"]!!.data!!.objectId) + assertEquals("hello", entries["title"]!!.data!!.string) // RTLMV4d4 + } + + @Test + fun `invalid counter value fails evaluation with 40003`(): Unit = runBlocking { + val bridge = FakeBridge() + val counter = LiveCounter.create(Double.NaN) as DefaultLiveCounter + val exception = assertFailsWith { counter.evaluate(bridge) } // RTLCV4a + assertEquals(400, exception.errorInfo.statusCode) + assertEquals(40003, exception.errorInfo.code) + } +} From 9280502bb427dbb47e6ec648e021673a52b99828 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 12 Jun 2026 16:48:34 +0530 Subject: [PATCH 7/7] Fixed ably-js parity issues in path-based liveobjects API implementation - PathObject path parsing: exact port of the ably-js escape algorithm (backslash before a non-dot is kept literally, so an escaped backslash no longer suppresses the dot split; trailing backslash kept as-is) - PathObjectSubscriptionRegister: deliver one event per full path to the updated object, so an object reachable via several covered paths notifies subscribers once per path (mirrors liveobject.ts#_notifyPathSubscriptions) - size()/keys()/entries() on map path objects and size() on map instances now exclude entries with unresolvable (dangling/tombstoned) object references, per RTLM10d/RTLM14 - Added parity tests for escaped-backslash parsing, multi-path event delivery, and unresolvable-reference filtering --- .../object/PathObjectSubscriptionRegister.kt | 32 ++++++++-------- .../object/instance/DefaultLiveMapInstance.kt | 4 +- .../lib/object/path/DefaultBasePathObject.kt | 37 +++++++++++-------- .../object/path/DefaultLiveMapPathObject.kt | 17 +++++++-- .../io/ably/lib/object/unit/PathApiTest.kt | 33 +++++++++++++++++ 5 files changed, 86 insertions(+), 37 deletions(-) diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/PathObjectSubscriptionRegister.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/PathObjectSubscriptionRegister.kt index 6240580c9..ae6953cbd 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/PathObjectSubscriptionRegister.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/PathObjectSubscriptionRegister.kt @@ -63,10 +63,6 @@ internal class PathObjectSubscriptionRegister(private val bridge: ObjectsBridge) val fullPaths = PathFinder.findFullPaths(bridge, objectId) if (fullPaths.isEmpty()) return // object not reachable from root - val candidatePaths = fullPaths.flatMap { fullPath -> - listOf(fullPath) + updatedKeys.map { key -> fullPath + key } - } - val publicMessage = message?.let { try { it.toPublicMessage(bridge.channelName) @@ -76,17 +72,23 @@ internal class PathObjectSubscriptionRegister(private val bridge: ObjectsBridge) } } - for (entry in subscriptions.values) { - // first candidate covered by this subscription wins (priority order) - val coveredPath = candidatePaths.firstOrNull { coversPath(entry.path, entry.depth, it) } ?: continue - val event = DefaultPathObjectSubscriptionEvent( - DefaultPathObject(bridge, coveredPath), // RTPO19e1 - publicMessage, // RTPO19e2 - ) - try { - entry.listener.onUpdated(event) - } catch (t: Throwable) { - Log.e(tag, "Error in PathObjectListener callback", t) + // one event per full path to the object (an object reachable via several + // covered paths notifies once per path), exactly like ably-js + // liveobject.ts#_notifyPathSubscriptions + for (fullPath in fullPaths) { + val candidatePaths = listOf(fullPath) + updatedKeys.map { key -> fullPath + key } + for (entry in subscriptions.values) { + // first candidate covered by this subscription wins (priority order) + val coveredPath = candidatePaths.firstOrNull { coversPath(entry.path, entry.depth, it) } ?: continue + val event = DefaultPathObjectSubscriptionEvent( + DefaultPathObject(bridge, coveredPath), // RTPO19e1 + publicMessage, // RTPO19e2 + ) + try { + entry.listener.onUpdated(event) + } catch (t: Throwable) { + Log.e(tag, "Error in PathObjectListener callback", t) + } } } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultLiveMapInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultLiveMapInstance.kt index b43ea27dd..10bf58e24 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultLiveMapInstance.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultLiveMapInstance.kt @@ -64,10 +64,10 @@ internal class DefaultLiveMapInstance( /** Spec: RTINS8 */ override fun values(): Iterable = entries().map { it.value } - /** Spec: RTINS9 / RTTS10a - non-null (the wrapped value is always a map) */ + /** Spec: RTINS9 / RTTS10a - non-null; counts resolvable entries only (RTLM10d/RTLM14) */ override fun size(): Long { bridge.throwIfInvalidAccessApiConfiguration() // RTO25 - return mapNodeOrThrow().entries().size.toLong() + return mapNodeOrThrow().entries().count { (_, data) -> data.resolve(bridge) != null }.toLong() } /** Spec: RTINS12 */ diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultBasePathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultBasePathObject.kt index 013bb2114..187bc6e08 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultBasePathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultBasePathObject.kt @@ -101,33 +101,38 @@ internal abstract class DefaultBasePathObject( internal companion object { /** - * Parses a dot-delimited path string into segments; a backslash-escaped - * dot (`\.`) is a literal dot within a segment. + * Parses a dot-delimited path string into segments: splits on unescaped + * dots; a backslash-escaped dot (`\.`) is a literal dot within a segment; + * a backslash before any other character is kept as-is. Exact port of the + * ably-js algorithm (pathobject.ts#at) so the two SDKs agree on every + * input, including escaped backslashes and trailing backslashes. * * Spec: RTPO6 (and the inverse of RTPO4b) */ internal fun parsePath(path: String): List { val segments = mutableListOf() val current = StringBuilder() - var i = 0 - while (i < path.length) { - val c = path[i] - when { - c == '\\' && i + 1 < path.length && path[i + 1] == '.' -> { - current.append('.') - i += 2 - } - c == '.' -> { + var escaping = false + for (c in path) { + if (escaping) { + // keep the escape character if not escaping a dot + if (c != '.') current.append('\\') + current.append(c) + escaping = false + continue + } + when (c) { + '\\' -> escaping = true + '.' -> { segments.add(current.toString()) current.setLength(0) - i++ - } - else -> { - current.append(c) - i++ } + else -> current.append(c) } } + if (escaping) { + current.append('\\') + } segments.add(current.toString()) return segments } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultLiveMapPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultLiveMapPathObject.kt index 4cfd2a60b..4f4571ca9 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultLiveMapPathObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultLiveMapPathObject.kt @@ -12,6 +12,7 @@ import io.ably.lib.`object`.invalidInputError import io.ably.lib.`object`.objectDataFrom import io.ably.lib.`object`.path.types.LiveMapPathObject import io.ably.lib.`object`.pathNotResolvedError +import io.ably.lib.`object`.resolve import io.ably.lib.`object`.typeMismatchError import io.ably.lib.`object`.value.LiveMapValue import java.util.AbstractMap @@ -36,6 +37,14 @@ internal class DefaultLiveMapPathObject( private fun mapNodeOrNull(): MapNode? = (resolve() as? ResolvedValue.MapRef)?.map + /** + * Keys of the resolved map whose entries themselves resolve - entries + * referencing missing/tombstoned objects are excluded, matching the + * underlying map semantics (RTLM11d2/RTLM14). + */ + private fun resolvableKeys(node: MapNode): List = + node.entries().filter { (_, data) -> data.resolve(bridge) != null }.map { it.key } + /** Spec: RTPO5 - purely navigational, no resolution performed */ override fun get(key: String): PathObject = DefaultPathObject(bridge, pathSegments + key) @@ -47,7 +56,7 @@ internal class DefaultLiveMapPathObject( override fun entries(): Iterable> { bridge.throwIfInvalidAccessApiConfiguration() // RTPO9a / RTO25 val node = mapNodeOrNull() ?: return emptyList() - return node.entries().keys.map { key -> + return resolvableKeys(node).map { key -> AbstractMap.SimpleImmutableEntry(key, get(key)) // child paths as if by get(key) } } @@ -56,16 +65,16 @@ internal class DefaultLiveMapPathObject( override fun keys(): Iterable { bridge.throwIfInvalidAccessApiConfiguration() // RTPO10a / RTO25 val node = mapNodeOrNull() ?: return emptyList() - return node.entries().keys.toList() + return resolvableKeys(node) } /** Spec: RTPO11 */ override fun values(): Iterable = entries().map { it.value } - /** Spec: RTPO12 - null when the path does not resolve to a map */ + /** Spec: RTPO12 - null when the path does not resolve to a map; counts resolvable entries (RTLM10d) */ override fun size(): Long? { bridge.throwIfInvalidAccessApiConfiguration() // RTPO12a / RTO25 - return mapNodeOrNull()?.entries()?.size?.toLong() + return mapNodeOrNull()?.let { resolvableKeys(it).size.toLong() } } /** Spec: RTPO15 */ diff --git a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/PathApiTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/PathApiTest.kt index abeeb5cf2..15f3fc9c6 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/object/unit/PathApiTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/object/unit/PathApiTest.kt @@ -48,6 +48,11 @@ internal class PathApiTest { // RTPO6 - parsing honours the escape assertEquals(listOf("a.b", "c"), DefaultBasePathObject.parsePath("a\\.b.c")) assertEquals(listOf("users", "emma"), DefaultBasePathObject.parsePath("users.emma")) + // ably-js parity: a backslash NOT escaping a dot is kept as-is, so an + // escaped backslash before a dot does not suppress the split + assertEquals(listOf("a\\\\", "b"), DefaultBasePathObject.parsePath("a\\\\.b")) + // ably-js parity: trailing backslash is kept literally + assertEquals(listOf("x\\"), DefaultBasePathObject.parsePath("x\\")) } @Test @@ -164,6 +169,34 @@ internal class PathApiTest { assertEquals(1, events.size) } + @Test + fun `object reachable via several covered paths notifies once per path`() { + val bridge = graphBridge() + // second reference to the same counter directly under root + (bridge.nodes["root"] as FakeMapNode).data["topScore"] = WireObjectData(objectId = "counter:score@1") + + val events = mutableListOf() + DefaultLiveMapPathObject(bridge, emptyList()).subscribe { events.add(it.getObject().path()) } + + bridge.notifyUpdated("counter:score@1", emptySet(), null) + + // ably-js parity (liveobject.ts#_notifyPathSubscriptions): one event per full path + assertEquals(setOf("profile.score", "topScore"), events.toSet()) + assertEquals(2, events.size) + } + + @Test + fun `size and keys exclude entries with unresolvable references`() { + val bridge = graphBridge() + // dangling reference - the target object does not exist in the pool + (bridge.nodes["root"] as FakeMapNode).data["ghost"] = WireObjectData(objectId = "map:deleted@1") + + val root = DefaultLiveMapPathObject(bridge, emptyList()) + assertEquals(2L, root.size()) // RTLM10d/RTLM14 - ghost not counted + assertEquals(setOf("profile", "flag"), root.keys().toSet()) + assertEquals(2L, root.instance()!!.asLiveMap().size()) // RTINS9 - same filtering + } + @Test fun `instance subscriptions deliver events with the public message`() { val bridge = graphBridge()