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 new file mode 100644 index 000000000..c045a075c --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/ValueType.java @@ -0,0 +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 new file mode 100644 index 000000000..e2c9cbed3 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/Instance.java @@ -0,0 +1,149 @@ +package io.ably.lib.object.instance; + +import com.google.gson.JsonElement; +import io.ably.lib.object.ValueType; +import io.ably.lib.object.instance.types.BinaryInstance; +import io.ably.lib.object.instance.types.BooleanInstance; +import io.ably.lib.object.instance.types.JsonArrayInstance; +import io.ably.lib.object.instance.types.JsonObjectInstance; +import io.ably.lib.object.instance.types.LiveCounterInstance; +import io.ably.lib.object.instance.types.LiveMapInstance; +import io.ably.lib.object.instance.types.NumberInstance; +import io.ably.lib.object.instance.types.StringInstance; +import org.jetbrains.annotations.NotNull; + +/** + * A direct-reference view of a single resolved LiveObject ({@code LiveMap} or + * {@code LiveCounter}) or primitive value. + * + *
Unlike {@code PathObject}, which re-resolves its path on every call, an + * {@code Instance} is identity-addressed: it is bound to a specific underlying value + * and dereferenced in O(1), regardless of where that value sits in the graph. Read + * operations validate the access API preconditions and fail with an + * {@code AblyException} if they are not satisfied. + * + *
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 { + + /** + * 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(); + + /** + * 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 / 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(); + + /** + * 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}. + * + *
Spec: RTTS9a + * + * @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. + * + *
Spec: RTTS9b + * + * @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. + * + *
Spec: RTTS9c + * + * @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. + * + *
Spec: RTTS9c + * + * @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. + * + *
Spec: RTTS9c + * + * @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. + * + *
Spec: RTTS9c + * + * @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. + * + *
Spec: RTTS9c + * + * @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. + * + *
Spec: RTTS9c + * + * @return a {@link JsonArrayInstance} view of this instance + */ + @NotNull JsonArrayInstance asJsonArray(); +} 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 new file mode 100644 index 000000000..91e8b7023 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/types/BinaryInstance.java @@ -0,0 +1,25 @@ +package io.ably.lib.object.instance.types; + +import io.ably.lib.object.instance.Instance; +import org.jetbrains.annotations.NotNull; + +/** + * A read-only {@link Instance} bound to a binary primitive value + * (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 / RTTS10c + * + * @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..c4ec1a01e --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/types/BooleanInstance.java @@ -0,0 +1,25 @@ +package io.ably.lib.object.instance.types; + +import io.ably.lib.object.instance.Instance; +import org.jetbrains.annotations.NotNull; + +/** + * A read-only {@link Instance} bound to a {@code Boolean} primitive value. + * 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 / RTTS10c + * + * @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..f85fc0865 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/types/JsonArrayInstance.java @@ -0,0 +1,26 @@ +package io.ably.lib.object.instance.types; + +import com.google.gson.JsonArray; +import io.ably.lib.object.instance.Instance; +import org.jetbrains.annotations.NotNull; + +/** + * A read-only {@link Instance} bound to a {@link JsonArray} primitive value. + * 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 / RTTS10c + * + * @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..7fce7183d --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/types/JsonObjectInstance.java @@ -0,0 +1,26 @@ +package io.ably.lib.object.instance.types; + +import com.google.gson.JsonObject; +import io.ably.lib.object.instance.Instance; +import org.jetbrains.annotations.NotNull; + +/** + * A read-only {@link Instance} bound to a {@link JsonObject} primitive value. + * 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 / RTTS10c + * + * @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..c80b91f91 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/object/instance/types/LiveCounterInstance.java @@ -0,0 +1,104 @@ +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; + +/** + * 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 { + + /** + * 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}. + * + *
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 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 Spec: RTINS15a1 (default {@code amount} of {@code 1})
+ *
+ * @return a future that completes when the operation has been acknowledged
+ */
+ @NotNull
+ CompletableFuture Spec: RTINS15
+ *
+ * @param amount the amount to subtract (may be negative)
+ * @return a future that completes when the operation has been acknowledged
+ */
+ @NotNull
+ CompletableFuture 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
new file mode 100644
index 000000000..a6c3fb2d4
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/object/instance/types/LiveMapInstance.java
@@ -0,0 +1,137 @@
+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 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;
+
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * 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.
+ *
+ * Spec: RTTS10a
+ */
+public interface LiveMapInstance extends Instance {
+
+ /**
+ * 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
+ *
+ * @param key the key to look up
+ * @return an instance wrapping the value at {@code key}, or {@code null}
+ */
+ @Nullable
+ Instance get(@NotNull String key);
+
+ /**
+ * Returns the entries (key, child {@link Instance}) of the wrapped
+ * {@code LiveMap}.
+ *
+ * Spec: RTINS6
+ *
+ * @return an unmodifiable iterable of entries
+ */
+ @NotNull
+ @Unmodifiable
+ Iterable Spec: RTINS7
+ *
+ * @return an unmodifiable iterable of keys
+ */
+ @NotNull
+ @Unmodifiable
+ Iterable Spec: RTINS8
+ *
+ * @return an unmodifiable iterable of value instances
+ */
+ @NotNull
+ @Unmodifiable
+ Iterable 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 Spec: RTINS13
+ *
+ * @param key the key to remove
+ * @return a future that completes when the operation has been acknowledged
+ */
+ @NotNull
+ CompletableFuture 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
new file mode 100644
index 000000000..4e94637f5
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/object/instance/types/NumberInstance.java
@@ -0,0 +1,25 @@
+package io.ably.lib.object.instance.types;
+
+import io.ably.lib.object.instance.Instance;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * A read-only {@link Instance} bound to a {@code Number} primitive value.
+ * 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 / RTTS10c
+ *
+ * @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..06e39a417
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/object/instance/types/StringInstance.java
@@ -0,0 +1,25 @@
+package io.ably.lib.object.instance.types;
+
+import io.ably.lib.object.instance.Instance;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * A read-only {@link Instance} bound to a {@code String} primitive value.
+ * 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 / RTTS10c
+ *
+ * @return the wrapped string value
+ */
+ @NotNull
+ 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 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..7c2570634
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/object/message/ObjectData.java
@@ -0,0 +1,71 @@
+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. The returned array is the underlying message
+ * payload and is not defensively copied; callers must treat it as read-only.
+ *
+ * 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:
+ *
+ * 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:
+ *
+ * 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
new file mode 100644
index 000000000..0e60bb378
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/object/path/PathObject.java
@@ -0,0 +1,225 @@
+package io.ably.lib.object.path;
+
+import com.google.gson.JsonElement;
+import io.ably.lib.object.ValueType;
+import io.ably.lib.object.instance.Instance;
+import io.ably.lib.object.path.types.BinaryPathObject;
+import io.ably.lib.object.path.types.BooleanPathObject;
+import io.ably.lib.object.path.types.JsonArrayPathObject;
+import io.ably.lib.object.path.types.JsonObjectPathObject;
+import io.ably.lib.object.path.types.LiveCounterPathObject;
+import io.ably.lib.object.path.types.LiveMapPathObject;
+import io.ably.lib.object.path.types.NumberPathObject;
+import io.ably.lib.object.path.types.StringPathObject;
+import io.ably.lib.object.Subscription;
+import org.jetbrains.annotations.NonBlocking;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A lazy, path-based reference into the LiveObjects graph rooted at the channel's root
+ * {@code LiveMap}.
+ *
+ * A {@code PathObject} stores a path as an ordered list of string segments and
+ * resolves it against the local object graph each time a method is called. Resolution
+ * is best-effort: the value at a path may change between two calls (e.g. between
+ * {@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.
+ *
+ * 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, RTTS3
+ *
+ * @see LiveMapPathObject
+ * @see LiveCounterPathObject
+ * @see PathObjectListener
+ */
+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();
+
+ /**
+ * 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 / RTTS3a
+ *
+ * @return the dot-delimited path from the root to this position
+ */
+ @NotNull String path();
+
+ /**
+ * 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
+ * no object id), when the path does not resolve, or when called on primitive
+ * {@code *PathObject} sub-types.
+ *
+ * Spec: RTPO8 / RTTS3b
+ *
+ * @return a {@link Instance} wrapping the resolved live object, or {@code null}
+ */
+ @Nullable Instance 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 / RTTS3c
+ *
+ * @return the compacted JSON snapshot, or {@code null} if the path does not resolve
+ */
+ @Nullable JsonElement compactJson();
+
+ /**
+ * 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.
+ *
+ * Spec: RTTS4a
+ *
+ * @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}.
+ *
+ * Spec: RTTS5a
+ *
+ * @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.
+ *
+ * Spec: RTTS5b
+ *
+ * @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.
+ *
+ * Spec: RTTS5c
+ *
+ * @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.
+ *
+ * Spec: RTTS5c
+ *
+ * @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.
+ *
+ * Spec: RTTS5c
+ *
+ * @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.
+ *
+ * Spec: RTTS5c
+ *
+ * @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.
+ *
+ * Spec: RTTS5c
+ *
+ * @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.
+ *
+ * Spec: RTTS5c
+ *
+ * @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);
+}
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..cf83c3ae4
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/object/path/PathObjectSubscriptionOptions.java
@@ -0,0 +1,58 @@
+package io.ably.lib.object.path;
+
+import io.ably.lib.types.AblyException;
+import io.ably.lib.types.ErrorInfo;
+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 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
+ * @throws AblyException with {@code statusCode} 400 and {@code code} 40003 if
+ * {@code depth} is not a positive integer
+ */
+ 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;
+ }
+
+ /**
+ * 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
new file mode 100644
index 000000000..f47765cea
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/object/path/types/BinaryPathObject.java
@@ -0,0 +1,29 @@
+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#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.
+ *
+ * Spec: RTTS6c
+ */
+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 / RTTS6c
+ *
+ * @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..b582227c8
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/object/path/types/BooleanPathObject.java
@@ -0,0 +1,29 @@
+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#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.
+ *
+ * Spec: RTTS6c
+ */
+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 / RTTS6c
+ *
+ * @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..585980bf8
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/object/path/types/JsonArrayPathObject.java
@@ -0,0 +1,30 @@
+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#instance()} returns {@code null}
+ * 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.
+ *
+ * Spec: RTTS6c
+ */
+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 / RTTS6c
+ *
+ * @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..681fcaa6e
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/object/path/types/JsonObjectPathObject.java
@@ -0,0 +1,30 @@
+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#instance()} returns {@code null}
+ * 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.
+ *
+ * Spec: RTTS6c
+ */
+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 / RTTS6c
+ *
+ * @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..bb2588213
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/object/path/types/LiveCounterPathObject.java
@@ -0,0 +1,87 @@
+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 - 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
+ * 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 {
+
+ /**
+ * 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 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 Spec: RTPO18a1 (default {@code amount} of {@code 1})
+ *
+ * @return a future that completes when the operation has been acknowledged
+ */
+ @NotNull
+ CompletableFuture Spec: RTPO18
+ *
+ * @param amount the amount to subtract (may be negative)
+ * @return a future that completes when the operation has been acknowledged
+ */
+ @NotNull
+ CompletableFuture 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.
+ *
+ * Spec: RTTS6a
+ */
+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 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").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
+ * 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
+ * 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 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 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 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 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 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.
+ *
+ * Spec: RTTS6c
+ */
+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 / RTTS6c
+ *
+ * @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..06c332994
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/object/path/types/StringPathObject.java
@@ -0,0 +1,29 @@
+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#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.
+ *
+ * Spec: RTTS6c
+ */
+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 / RTTS6c
+ *
+ * @return the resolved string, or {@code null}
+ */
+ @Nullable
+ String value();
+}
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. 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 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. 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
+ */
+ @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.clone();
+ }
+
+ @Override
+ public @NotNull Object getValue() {
+ return value.clone();
+ }
+
+ @Override
+ public boolean isBinary() { return true; }
+
+ @Override
+ public byte @NotNull [] getAsBinary() { return value.clone(); }
+ }
+
+ /**
+ * 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;
{@code
+ * ObjectMessage
+ * └── getOperation() → ObjectOperation
+ * ├── getAction() → ObjectOperationAction (enum)
+ * ├── getMapCreate() → MapCreate → ObjectsMapSemantics, Map
+ *
+ * {@code
+ * ObjectMessage (delivered in subscription events)
+ * └── getOperation() → ObjectOperation
+ * ├── getAction() → ObjectOperationAction (enum)
+ * ├── getMapCreate() → MapCreate
+ * │ ├── getSemantics() → ObjectsMapSemantics (enum)
+ * │ └── getEntries() → Map
+ *
+ *