diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 5017a0732..c60db1d41 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -23,4 +23,4 @@ jobs: distribution: 'temurin' - name: Set up Gradle uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 - - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectUnitTests :uts:test + - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectsUnitTests :uts:test diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 1c59f771a..8250c30aa 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -122,4 +122,4 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 - - run: ./gradlew runLiveObjectIntegrationTests + - run: ./gradlew runLiveObjectsIntegrationTests diff --git a/android/src/main/java/io/ably/lib/realtime/Channel.java b/android/src/main/java/io/ably/lib/realtime/Channel.java index baf086cbc..c0677ba69 100644 --- a/android/src/main/java/io/ably/lib/realtime/Channel.java +++ b/android/src/main/java/io/ably/lib/realtime/Channel.java @@ -3,7 +3,7 @@ import io.ably.lib.types.AblyException; import io.ably.lib.types.ChannelOptions; import io.ably.lib.push.PushChannel; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.liveobjects.LiveObjectsPlugin; public class Channel extends ChannelBase { diff --git a/java/src/main/java/io/ably/lib/realtime/Channel.java b/java/src/main/java/io/ably/lib/realtime/Channel.java index 539d08adf..f4100162f 100644 --- a/java/src/main/java/io/ably/lib/realtime/Channel.java +++ b/java/src/main/java/io/ably/lib/realtime/Channel.java @@ -1,6 +1,6 @@ package io.ably.lib.realtime; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.liveobjects.LiveObjectsPlugin; import io.ably.lib.types.AblyException; import io.ably.lib.types.ChannelOptions; import org.jetbrains.annotations.Nullable; diff --git a/lib/src/main/java/io/ably/lib/liveobjects/LiveObjectsPlugin.java b/lib/src/main/java/io/ably/lib/liveobjects/LiveObjectsPlugin.java new file mode 100644 index 000000000..41ab0622c --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/LiveObjectsPlugin.java @@ -0,0 +1,108 @@ +package io.ably.lib.liveobjects; + +import io.ably.lib.liveobjects.adapter.AblyClientAdapter; +import io.ably.lib.liveobjects.adapter.Adapter; +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.ChannelState; +import io.ably.lib.types.ProtocolMessage; +import io.ably.lib.util.Log; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.InvocationTargetException; + +/** + * The LiveObjectsPlugin interface provides a mechanism for managing and interacting with + * live data objects in a real-time environment. It allows for the retrieval, disposal, and + * management of Objects instances associated with specific channel names. + */ +public interface LiveObjectsPlugin { + + /** + * Retrieves an instance of RealtimeObjects associated with the specified channel name. + * This method ensures that a RealtimeObjects instance is available for the given channel, + * creating one if it does not already exist. + * + * @param channelName the name of the channel for which the RealtimeObjects instance is to be retrieved. + * @return the RealtimeObjects instance associated with the specified channel name. + */ + @NotNull + RealtimeObject getInstance(@NotNull String channelName); + + /** + * Handles a protocol message. + * This method is invoked whenever a protocol message is received, allowing the implementation + * to process the message and take appropriate actions. + * + * @param message the protocol message to handle. + */ + void handle(@NotNull ProtocolMessage message); + + /** + * Handles state changes for a specific channel. + * This method is invoked whenever a channel's state changes, allowing the implementation + * to update the RealtimeObjects instances accordingly based on the new state and presence of objects. + * + * @param channelName the name of the channel whose state has changed. + * @param state the new state of the channel. + * @param hasObjects flag indicates whether the channel has any associated objects. + */ + void handleStateChange(@NotNull String channelName, @NotNull ChannelState state, boolean hasObjects); + + /** + * Disposes of the RealtimeObjects instance associated with the specified channel name. + * This method removes the RealtimeObjects instance for the given channel, releasing any + * resources associated with it. + * This is invoked when ablyRealtimeClient.channels.release(channelName) is called + * + * @param channelName the name of the channel whose RealtimeObjects instance is to be removed. + */ + void dispose(@NotNull String channelName); + + /** + * Disposes of the plugin instance and all underlying resources. + * This is invoked when ablyRealtimeClient.close() is called + */ + void dispose(); + + /** + * Attempts to initialize the LiveObjects plugin by reflectively loading its implementation + * from the classpath. Returns a new plugin instance on every successful invocation, or + * {@code null} if the LiveObjects plugin is not present in the classpath. + * + * @param ablyRealtime the AblyRealtime client used to build the adapter the plugin runs against. + * @return a new {@link LiveObjectsPlugin} instance, or {@code null} if the plugin is unavailable. + */ + @Nullable + static LiveObjectsPlugin tryInitialize(@NotNull AblyRealtime ablyRealtime) { + return Factory.create(ablyRealtime); + } + + /** + * Reflectively constructs the LiveObjects plugin implementation. Lives in a nested class so the + * implementation-class name stays {@code private} (interface fields are forced {@code public}), + * mirroring {@link io.ably.lib.liveobjects.serialization.ObjectSerializer.Holder}. Unlike {@code Holder} + * this is stateless: {@link #create} returns a new instance on every call. + */ + final class Factory { + private static final String TAG = LiveObjectsPlugin.Factory.class.getName(); + private static final String IMPLEMENTATION_CLASS = "io.ably.lib.liveobjects.DefaultLiveObjectsPlugin"; + + private Factory() {} + + @Nullable + static LiveObjectsPlugin create(@NotNull AblyRealtime ablyRealtime) { + try { + Class objectsImplementation = Class.forName(IMPLEMENTATION_CLASS); + AblyClientAdapter adapter = new Adapter(ablyRealtime); + return (LiveObjectsPlugin) objectsImplementation + .getDeclaredConstructor(AblyClientAdapter.class) + .newInstance(adapter); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | + InvocationTargetException e) { + Log.i(TAG, "LiveObjects plugin not found in classpath. LiveObjects functionality will not be available.", e); + return null; + } + } + } +} diff --git a/lib/src/main/java/io/ably/lib/liveobjects/RealtimeObject.java b/lib/src/main/java/io/ably/lib/liveobjects/RealtimeObject.java new file mode 100644 index 000000000..e9dc90a7d --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/RealtimeObject.java @@ -0,0 +1,93 @@ +package io.ably.lib.liveobjects; + +import io.ably.lib.liveobjects.path.types.LiveMapPathObject; +import io.ably.lib.liveobjects.state.ObjectStateChange; +import io.ably.lib.liveobjects.state.ObjectStateEvent; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ErrorInfo; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; + +/** + * The RealtimeObject interface is the entry point to the strongly-typed, path-based + * LiveObjects API on a channel. It exposes the root of the objects graph as a + * {@link LiveMapPathObject} and, via {@link ObjectStateChange}, lets callers observe + * synchronization state transitions for the channel's objects. + * + *

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

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

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

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

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

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

A stateless singleton ({@link #INSTANCE}) shared across all channels that lack the + * plugin. Adding a method to {@link RealtimeObject} will fail compilation here until it is + * guarded, which is the intended safety net. + */ + final class Unavailable implements RealtimeObject { + + public static final Unavailable INSTANCE = new Unavailable(); + + private Unavailable() {} + + @Override + public @NotNull CompletableFuture get() { + throw missing(); + } + + @Override + public Subscription on(@NotNull ObjectStateEvent event, ObjectStateChange.@NotNull Listener listener) { + throw missing(); + } + + @Override + public void off(ObjectStateChange.@NotNull Listener listener) { + throw missing(); + } + + @Override + public void offAll() { + throw missing(); + } + + private static RuntimeException missing() { + return new IllegalStateException("LiveObjects plugin hasn't been installed", AblyException.fromErrorInfo( + new ErrorInfo("add runtimeOnly('io.ably:liveobjects:') to your dependency tree", 400, 40019) + )); + } + } +} diff --git a/lib/src/main/java/io/ably/lib/liveobjects/Subscription.java b/lib/src/main/java/io/ably/lib/liveobjects/Subscription.java new file mode 100644 index 000000000..720bf9a79 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/Subscription.java @@ -0,0 +1,30 @@ +package io.ably.lib.liveobjects; + +/** + * 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/liveobjects/ValueType.java b/lib/src/main/java/io/ably/lib/liveobjects/ValueType.java new file mode 100644 index 000000000..ac050adf1 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/ValueType.java @@ -0,0 +1,28 @@ +package io.ably.lib.liveobjects; + +/** + * 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 by {@code PathObject#getType()} only when a value is present but matches none of the known types. Never produced by an {@code Instance} in normal operation. Spec: RTTS2a9 */ + UNKNOWN, +} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsAdapter.java b/lib/src/main/java/io/ably/lib/liveobjects/adapter/AblyClientAdapter.java similarity index 75% rename from lib/src/main/java/io/ably/lib/objects/ObjectsAdapter.java rename to lib/src/main/java/io/ably/lib/liveobjects/adapter/AblyClientAdapter.java index b6054e71a..6a63a8783 100644 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsAdapter.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/adapter/AblyClientAdapter.java @@ -1,4 +1,4 @@ -package io.ably.lib.objects; +package io.ably.lib.liveobjects.adapter; import io.ably.lib.realtime.ChannelBase; import io.ably.lib.realtime.Connection; @@ -7,7 +7,15 @@ import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NotNull; -public interface ObjectsAdapter { +/** + * Bridges the path-based LiveObjects implementation to the core Ably client, exposing the + * client configuration, connection and channel state it needs without coupling it to the + * concrete {@link io.ably.lib.realtime.AblyRealtime} type. + * + *

This is the adapter for the path-based {@code io.ably.lib.object} API and is intentionally + * kept independent of the legacy {@code io.ably.lib.objects} package. + */ +public interface AblyClientAdapter { /** * Retrieves the client options configured for the Ably client. * Used to access client configuration parameters such as echoMessages setting @@ -43,4 +51,3 @@ public interface ObjectsAdapter { */ @NotNull ChannelBase getChannel(@NotNull String channelName) throws AblyException; } - diff --git a/lib/src/main/java/io/ably/lib/liveobjects/adapter/Adapter.java b/lib/src/main/java/io/ably/lib/liveobjects/adapter/Adapter.java new file mode 100644 index 000000000..a935f68bc --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/adapter/Adapter.java @@ -0,0 +1,56 @@ +package io.ably.lib.liveobjects.adapter; + +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; +import io.ably.lib.realtime.ChannelBase; +import io.ably.lib.realtime.Connection; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.ReadOnlyMap; +import io.ably.lib.util.Log; +import org.jetbrains.annotations.NotNull; + +/** + * Default {@link AblyClientAdapter} implementation backed by an {@link AblyRealtime} client. + * Holding the {@code AblyRealtime} reference gives the path-based LiveObjects implementation + * access to the full client configuration and runtime state it may need. + */ +public class Adapter implements AblyClientAdapter { + private final AblyRealtime ably; + private static final String TAG = AblyClientAdapter.class.getName(); + + public Adapter(@NotNull AblyRealtime ably) { + this.ably = ably; + } + + @Override + public @NotNull ClientOptions getClientOptions() { + return ably.options; + } + + @Override + public @NotNull Connection getConnection() { + return ably.connection; + } + + @Override + public long getTime() throws AblyException { + return ably.time(); + } + + @Override + public @NotNull ChannelBase getChannel(@NotNull String channelName) throws AblyException { + // Look up via the read-only map view. Channels#get(String) would create the channel if + // absent; ReadOnlyMap only exposes get(Object), which returns null atomically for an + // unknown channel instead of silently recreating it. + final ReadOnlyMap channels = ably.channels; + final ChannelBase channel = channels.get(channelName); + if (channel == null) { + Log.e(TAG, "getChannel(): channel not found: " + channelName); + ErrorInfo errorInfo = new ErrorInfo("Channel not found: " + channelName, 404); + throw AblyException.fromErrorInfo(errorInfo); + } + return channel; + } +} diff --git a/lib/src/main/java/io/ably/lib/liveobjects/adapter/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/adapter/package-info.java new file mode 100644 index 000000000..3539eba35 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/adapter/package-info.java @@ -0,0 +1,10 @@ +/** + * Adapter layer bridging the path-based LiveObjects implementation to the core Ably client. + * {@link io.ably.lib.liveobjects.adapter.AblyClientAdapter} is the abstraction the implementation + * depends on; {@link io.ably.lib.liveobjects.adapter.Adapter} is the default implementation backed + * by an {@link io.ably.lib.realtime.AblyRealtime} client. + * + *

This package is intentionally independent of the legacy {@code io.ably.lib.objects} + * package so the path-based API can evolve on its own. + */ +package io.ably.lib.liveobjects.adapter; diff --git a/lib/src/main/java/io/ably/lib/liveobjects/instance/Instance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/Instance.java new file mode 100644 index 000000000..a364e9b3b --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/Instance.java @@ -0,0 +1,169 @@ +package io.ably.lib.liveobjects.instance; + +import com.google.gson.JsonElement; +import io.ably.lib.liveobjects.ValueType; +import io.ably.lib.liveobjects.instance.types.BinaryInstance; +import io.ably.lib.liveobjects.instance.types.BooleanInstance; +import io.ably.lib.liveobjects.instance.types.JsonArrayInstance; +import io.ably.lib.liveobjects.instance.types.JsonObjectInstance; +import io.ably.lib.liveobjects.instance.types.LiveCounterInstance; +import io.ably.lib.liveobjects.instance.types.LiveMapInstance; +import io.ably.lib.liveobjects.instance.types.NumberInstance; +import io.ably.lib.liveobjects.instance.types.StringInstance; +import org.jetbrains.annotations.NotNull; + +/** + * 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 wraps an already-resolved value (typically + * obtained from a {@code PathObject}), so its type is fixed and known for the lifetime + * of the instance, and it is dereferenced in O(1) regardless of where that value sits + * in the graph. Read operations validate the access API preconditions and fail with an + * {@code AblyException} if those are not satisfied. + * + *

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

Spec: RTINS1, RTTS7 + * + * @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 viewed as a {@link LiveMapInstance}. + * + *

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

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

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

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

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

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

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

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

Spec: RTTS9c / RTTS9d + * + * @return a {@link JsonArrayInstance} view of this instance + * @throws IllegalStateException if the wrapped value is not a JSON array + */ + @NotNull JsonArrayInstance asJsonArray(); +} diff --git a/lib/src/main/java/io/ably/lib/liveobjects/instance/InstanceListener.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/InstanceListener.java new file mode 100644 index 000000000..942c0635b --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/InstanceListener.java @@ -0,0 +1,22 @@ +package io.ably.lib.liveobjects.instance; + +import io.ably.lib.liveobjects.instance.types.LiveCounterInstance; +import io.ably.lib.liveobjects.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/liveobjects/instance/InstanceSubscriptionEvent.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/InstanceSubscriptionEvent.java new file mode 100644 index 000000000..db460035b --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/InstanceSubscriptionEvent.java @@ -0,0 +1,37 @@ +package io.ably.lib.liveobjects.instance; + +import io.ably.lib.liveobjects.instance.types.LiveCounterInstance; +import io.ably.lib.liveobjects.instance.types.LiveMapInstance; +import io.ably.lib.liveobjects.message.ObjectMessage; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * 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/liveobjects/instance/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/package-info.java new file mode 100644 index 000000000..df272f592 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/package-info.java @@ -0,0 +1,12 @@ +/** + * The identity-addressed view of the LiveObjects graph. + * {@link io.ably.lib.liveobjects.instance.Instance} wraps a specific resolved + * LiveObject or primitive value and dereferences it in O(1), following the + * object wherever it sits in the graph. Type-specific operations live on the + * sub-types in {@link io.ably.lib.liveobjects.instance.types}; instance + * subscriptions use {@link io.ably.lib.liveobjects.instance.InstanceListener} and + * {@link io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent}. + * + *

Spec: RTINS1-RTINS16, RTTS7-RTTS9 + */ +package io.ably.lib.liveobjects.instance; diff --git a/lib/src/main/java/io/ably/lib/liveobjects/instance/types/BinaryInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/BinaryInstance.java new file mode 100644 index 000000000..e7564df31 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/BinaryInstance.java @@ -0,0 +1,37 @@ +package io.ably.lib.liveobjects.instance.types; + +import com.google.gson.JsonPrimitive; +import io.ably.lib.liveobjects.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(); + + /** + * Returns the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonPrimitive}: binary compacts to a base64-encoded JSON string. + * + *

Spec: RTTS7a + * + * @return the compacted JSON primitive + */ + @Override + @NotNull JsonPrimitive compactJson(); +} diff --git a/lib/src/main/java/io/ably/lib/liveobjects/instance/types/BooleanInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/BooleanInstance.java new file mode 100644 index 000000000..90eebf425 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/BooleanInstance.java @@ -0,0 +1,38 @@ +package io.ably.lib.liveobjects.instance.types; + +import com.google.gson.JsonPrimitive; +import io.ably.lib.liveobjects.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(); + + /** + * Returns the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonPrimitive}: a {@code BooleanInstance} always compacts to a single + * JSON primitive. + * + *

Spec: RTTS7a + * + * @return the compacted JSON primitive + */ + @Override + @NotNull JsonPrimitive compactJson(); +} diff --git a/lib/src/main/java/io/ably/lib/liveobjects/instance/types/JsonArrayInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/JsonArrayInstance.java new file mode 100644 index 000000000..ce916e907 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/JsonArrayInstance.java @@ -0,0 +1,37 @@ +package io.ably.lib.liveobjects.instance.types; + +import com.google.gson.JsonArray; +import io.ably.lib.liveobjects.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(); + + /** + * Returns the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonArray}: a {@code JsonArrayInstance} always compacts to a JSON array. + * + *

Spec: RTTS7a + * + * @return the compacted JSON array + */ + @Override + @NotNull JsonArray compactJson(); +} diff --git a/lib/src/main/java/io/ably/lib/liveobjects/instance/types/JsonObjectInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/JsonObjectInstance.java new file mode 100644 index 000000000..724fe5242 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/JsonObjectInstance.java @@ -0,0 +1,37 @@ +package io.ably.lib.liveobjects.instance.types; + +import com.google.gson.JsonObject; +import io.ably.lib.liveobjects.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(); + + /** + * Returns the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonObject}: a {@code JsonObjectInstance} always compacts to a JSON object. + * + *

Spec: RTTS7a + * + * @return the compacted JSON object + */ + @Override + @NotNull JsonObject compactJson(); +} diff --git a/lib/src/main/java/io/ably/lib/liveobjects/instance/types/LiveCounterInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/LiveCounterInstance.java new file mode 100644 index 000000000..dd2ba8a51 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/LiveCounterInstance.java @@ -0,0 +1,117 @@ +package io.ably.lib.liveobjects.instance.types; + +import com.google.gson.JsonPrimitive; +import io.ably.lib.liveobjects.instance.Instance; +import io.ably.lib.liveobjects.instance.InstanceListener; +import io.ably.lib.liveobjects.Subscription; +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.NotNull; + +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(); + + /** + * Returns the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonPrimitive}: a {@code LiveCounterInstance} always compacts to a numeric + * JSON primitive. + * + *

Spec: RTTS7a + * + * @return the compacted JSON primitive + */ + @Override + @NotNull JsonPrimitive compactJson(); + + /** + * Increments the wrapped {@code LiveCounter} by {@code 1}. Equivalent to + * calling {@link #increment(Number)} with {@code 1}. + * + *

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); + + /** + * 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/liveobjects/instance/types/LiveMapInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/LiveMapInstance.java new file mode 100644 index 000000000..6e7c21e47 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/LiveMapInstance.java @@ -0,0 +1,150 @@ +package io.ably.lib.liveobjects.instance.types; + +import com.google.gson.JsonObject; +import io.ably.lib.liveobjects.instance.Instance; +import io.ably.lib.liveobjects.instance.InstanceListener; +import io.ably.lib.liveobjects.Subscription; +import io.ably.lib.liveobjects.value.LiveMapValue; +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +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 the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonObject}: a {@code LiveMapInstance} compacts to a JSON object (or, for a + * cyclic reference, an object-id reference object). + * + *

Spec: RTTS7a + * + * @return the compacted JSON object + */ + @Override + @NotNull JsonObject compactJson(); + + /** + * Returns a {@link Instance} wrapping the value at {@code key} of the + * wrapped {@code LiveMap}, or {@code null} when the key is absent / tombstoned. + * + *

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> 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 Instance}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); + + /** + * 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/liveobjects/instance/types/NumberInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/NumberInstance.java new file mode 100644 index 000000000..f18cb3c74 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/NumberInstance.java @@ -0,0 +1,38 @@ +package io.ably.lib.liveobjects.instance.types; + +import com.google.gson.JsonPrimitive; +import io.ably.lib.liveobjects.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(); + + /** + * Returns the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonPrimitive}: a {@code NumberInstance} always compacts to a single + * JSON primitive. + * + *

Spec: RTTS7a + * + * @return the compacted JSON primitive + */ + @Override + @NotNull JsonPrimitive compactJson(); +} diff --git a/lib/src/main/java/io/ably/lib/liveobjects/instance/types/StringInstance.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/StringInstance.java new file mode 100644 index 000000000..680f0c3ae --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/StringInstance.java @@ -0,0 +1,38 @@ +package io.ably.lib.liveobjects.instance.types; + +import com.google.gson.JsonPrimitive; +import io.ably.lib.liveobjects.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(); + + /** + * Returns the compacted JSON snapshot of the wrapped value, narrowed to a + * {@link JsonPrimitive}: a {@code StringInstance} always compacts to a single + * JSON primitive. + * + *

Spec: RTTS7a + * + * @return the compacted JSON primitive + */ + @Override + @NotNull JsonPrimitive compactJson(); +} diff --git a/lib/src/main/java/io/ably/lib/liveobjects/instance/types/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/instance/types/package-info.java new file mode 100644 index 000000000..651f45669 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/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.liveobjects.instance.types.LiveMapInstance} + * (RTTS10a) carries map reads, writes and subscribe, + * {@link io.ably.lib.liveobjects.instance.types.LiveCounterInstance} (RTTS10b) + * carries counter operations and subscribe, and the six primitive sub-types + * (RTTS10c) expose only a type-narrowed, non-null {@code value()}. + * + *

Spec: RTTS10 + */ +package io.ably.lib.liveobjects.instance.types; diff --git a/lib/src/main/java/io/ably/lib/liveobjects/message/CounterCreate.java b/lib/src/main/java/io/ably/lib/liveobjects/message/CounterCreate.java new file mode 100644 index 000000000..ca05e0e28 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/CounterCreate.java @@ -0,0 +1,21 @@ +package io.ably.lib.liveobjects.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/liveobjects/message/CounterInc.java b/lib/src/main/java/io/ably/lib/liveobjects/message/CounterInc.java new file mode 100644 index 000000000..88fe59174 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/CounterInc.java @@ -0,0 +1,22 @@ +package io.ably.lib.liveobjects.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/liveobjects/message/MapClear.java b/lib/src/main/java/io/ably/lib/liveobjects/message/MapClear.java new file mode 100644 index 000000000..29c9092dc --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/MapClear.java @@ -0,0 +1,12 @@ +package io.ably.lib.liveobjects.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/liveobjects/message/MapCreate.java b/lib/src/main/java/io/ably/lib/liveobjects/message/MapCreate.java new file mode 100644 index 000000000..fcd7fc539 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/MapCreate.java @@ -0,0 +1,33 @@ +package io.ably.lib.liveobjects.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/liveobjects/message/MapRemove.java b/lib/src/main/java/io/ably/lib/liveobjects/message/MapRemove.java new file mode 100644 index 000000000..fccb67464 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/MapRemove.java @@ -0,0 +1,21 @@ +package io.ably.lib.liveobjects.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/liveobjects/message/MapSet.java b/lib/src/main/java/io/ably/lib/liveobjects/message/MapSet.java new file mode 100644 index 000000000..6b63e88cf --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/MapSet.java @@ -0,0 +1,30 @@ +package io.ably.lib.liveobjects.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/liveobjects/message/ObjectData.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectData.java new file mode 100644 index 000000000..b98e53920 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectData.java @@ -0,0 +1,71 @@ +package io.ably.lib.liveobjects.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: OD2f + * + * @return the string value, or {@code null} if not applicable + */ + @Nullable String getString(); + + /** + * Returns the numeric value. + * + *

Spec: OD2e + * + * @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: OD2d + * + * @return the binary value, or {@code null} if not applicable + */ + byte @Nullable [] getBytes(); + + /** + * Returns the JSON object or array value. + * + *

Spec: OD2g + * + * @return the JSON value, or {@code null} if not applicable + */ + @Nullable JsonElement getJson(); +} diff --git a/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectDelete.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectDelete.java new file mode 100644 index 000000000..1f8c8c671 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectDelete.java @@ -0,0 +1,13 @@ +package io.ably.lib.liveobjects.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/liveobjects/message/ObjectMessage.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectMessage.java new file mode 100644 index 000000000..3a2513643 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectMessage.java @@ -0,0 +1,135 @@ +package io.ably.lib.liveobjects.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.liveobjects.path.PathObjectSubscriptionEvent} and + * {@link io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent}) so that user code can + * inspect the metadata of the message that triggered an object change. + * + *

An {@code ObjectMessage} always carries an {@link #getOperation() operation}; object + * 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/liveobjects/message/ObjectOperation.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperation.java new file mode 100644 index 000000000..d4df9fb00 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperation.java @@ -0,0 +1,106 @@ +package io.ably.lib.liveobjects.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/liveobjects/message/ObjectOperationAction.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperationAction.java new file mode 100644 index 000000000..002f289d4 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperationAction.java @@ -0,0 +1,37 @@ +package io.ably.lib.liveobjects.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/liveobjects/message/ObjectsMapEntry.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapEntry.java new file mode 100644 index 000000000..09df91fbb --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapEntry.java @@ -0,0 +1,51 @@ +package io.ably.lib.liveobjects.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/liveobjects/message/ObjectsMapSemantics.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapSemantics.java new file mode 100644 index 000000000..1fcb7f2aa --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapSemantics.java @@ -0,0 +1,18 @@ +package io.ably.lib.liveobjects.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/liveobjects/message/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/message/package-info.java new file mode 100644 index 000000000..f3f7c246e --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/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.liveobjects.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.liveobjects.message; diff --git a/lib/src/main/java/io/ably/lib/liveobjects/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/package-info.java new file mode 100644 index 000000000..722ac3994 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/package-info.java @@ -0,0 +1,17 @@ +/** + * The public, strongly-typed LiveObjects API: path-based and instance-based views + * over the objects graph on a channel. + * + *

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

Spec: RTTS1-RTTS10 (typed-SDK public API partition) + */ +package io.ably.lib.liveobjects; diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/PathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObject.java new file mode 100644 index 000000000..36a2ec49d --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObject.java @@ -0,0 +1,236 @@ +package io.ably.lib.liveobjects.path; + +import com.google.gson.JsonElement; +import io.ably.lib.liveobjects.ValueType; +import io.ably.lib.liveobjects.instance.Instance; +import io.ably.lib.liveobjects.path.types.BinaryPathObject; +import io.ably.lib.liveobjects.path.types.BooleanPathObject; +import io.ably.lib.liveobjects.path.types.JsonArrayPathObject; +import io.ably.lib.liveobjects.path.types.JsonObjectPathObject; +import io.ably.lib.liveobjects.path.types.LiveCounterPathObject; +import io.ably.lib.liveobjects.path.types.LiveMapPathObject; +import io.ably.lib.liveobjects.path.types.NumberPathObject; +import io.ably.lib.liveobjects.path.types.StringPathObject; +import io.ably.lib.liveobjects.Subscription; +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * 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 terminal method is called; + * the freshly resolved value is the sole basis for that call's result. Resolution is + * best-effort: the value at a path may change between two calls (e.g. between + * {@link #exists()} and a subsequent write) as updates from other clients are applied. + * + *

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

This base type exposes only the methods whose behaviour is independent of the + * resolved type; map and counter reads/writes are partitioned onto the sub-types + * (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 currently resolved at this path, or + * {@code null} when the path does not resolve to any value. Use this instead of + * dedicated {@code isLiveMap}/{@code isLiveCounter}/etc. checks. + * + *

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

Spec: RTTS4b + * + * @return the resolved value type at this path, or {@code null} if the path does + * not resolve to a value + */ + @Nullable 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/liveobjects/path/PathObjectListener.java b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectListener.java new file mode 100644 index 000000000..48bca1c72 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectListener.java @@ -0,0 +1,21 @@ +package io.ably.lib.liveobjects.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/liveobjects/path/PathObjectSubscriptionEvent.java b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionEvent.java new file mode 100644 index 000000000..7fd978de9 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionEvent.java @@ -0,0 +1,34 @@ +package io.ably.lib.liveobjects.path; + +import io.ably.lib.liveobjects.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/liveobjects/path/PathObjectSubscriptionOptions.java b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionOptions.java new file mode 100644 index 000000000..df9b5a9fa --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionOptions.java @@ -0,0 +1,58 @@ +package io.ably.lib.liveobjects.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/liveobjects/path/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/path/package-info.java new file mode 100644 index 000000000..00471f2fe --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/package-info.java @@ -0,0 +1,13 @@ +/** + * The path-addressed view of the LiveObjects graph. + * {@link io.ably.lib.liveobjects.path.PathObject} stores a path from the channel's + * root {@code LiveMap} and re-resolves it lazily on every call, so a reference + * survives object replacement at its path. Type-specific operations live on the + * sub-types in {@link io.ably.lib.liveobjects.path.types}; path-based subscriptions + * use {@link io.ably.lib.liveobjects.path.PathObjectListener}, + * {@link io.ably.lib.liveobjects.path.PathObjectSubscriptionEvent} and + * {@link io.ably.lib.liveobjects.path.PathObjectSubscriptionOptions}. + * + *

Spec: RTPO1-RTPO19, RTTS3-RTTS5 + */ +package io.ably.lib.liveobjects.path; diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/types/BinaryPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/BinaryPathObject.java new file mode 100644 index 000000000..e96ac7062 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/BinaryPathObject.java @@ -0,0 +1,29 @@ +package io.ably.lib.liveobjects.path.types; + +import io.ably.lib.liveobjects.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/liveobjects/path/types/BooleanPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/BooleanPathObject.java new file mode 100644 index 000000000..c77c59f9e --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/BooleanPathObject.java @@ -0,0 +1,29 @@ +package io.ably.lib.liveobjects.path.types; + +import io.ably.lib.liveobjects.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/liveobjects/path/types/JsonArrayPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonArrayPathObject.java new file mode 100644 index 000000000..52a89f016 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonArrayPathObject.java @@ -0,0 +1,30 @@ +package io.ably.lib.liveobjects.path.types; + +import com.google.gson.JsonArray; +import io.ably.lib.liveobjects.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/liveobjects/path/types/JsonObjectPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonObjectPathObject.java new file mode 100644 index 000000000..b889ab521 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonObjectPathObject.java @@ -0,0 +1,30 @@ +package io.ably.lib.liveobjects.path.types; + +import com.google.gson.JsonObject; +import io.ably.lib.liveobjects.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/liveobjects/path/types/LiveCounterPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveCounterPathObject.java new file mode 100644 index 000000000..fb8eb87d3 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveCounterPathObject.java @@ -0,0 +1,87 @@ +package io.ably.lib.liveobjects.path.types; + +import io.ably.lib.liveobjects.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 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/liveobjects/path/types/LiveMapPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveMapPathObject.java new file mode 100644 index 000000000..cf35e553f --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveMapPathObject.java @@ -0,0 +1,148 @@ +package io.ably.lib.liveobjects.path.types; + +import io.ably.lib.liveobjects.path.PathObject; +import io.ably.lib.liveobjects.value.LiveMapValue; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +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. + * + *

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> 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/liveobjects/path/types/NumberPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/NumberPathObject.java new file mode 100644 index 000000000..6eadd3697 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/NumberPathObject.java @@ -0,0 +1,29 @@ +package io.ably.lib.liveobjects.path.types; + +import io.ably.lib.liveobjects.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#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/liveobjects/path/types/StringPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/StringPathObject.java new file mode 100644 index 000000000..18652dc03 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/StringPathObject.java @@ -0,0 +1,29 @@ +package io.ably.lib.liveobjects.path.types; + +import io.ably.lib.liveobjects.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/liveobjects/path/types/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/package-info.java new file mode 100644 index 000000000..1575cfabc --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/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.liveobjects.path.types.LiveMapPathObject} (RTTS6a) + * carries map navigation and writes, + * {@link io.ably.lib.liveobjects.path.types.LiveCounterPathObject} (RTTS6b) carries + * counter operations, and the six primitive sub-types (RTTS6c) expose only a + * type-narrowed {@code value()}. + * + *

Spec: RTTS6 + */ +package io.ably.lib.liveobjects.path.types; diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java b/lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectJsonSerializer.java similarity index 72% rename from lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java rename to lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectJsonSerializer.java index b96954ca8..c6f95d200 100644 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectJsonSerializer.java @@ -1,4 +1,4 @@ -package io.ably.lib.objects; +package io.ably.lib.liveobjects.serialization; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; @@ -11,14 +11,14 @@ import java.lang.reflect.Type; -public class ObjectsJsonSerializer implements JsonSerializer, JsonDeserializer { - private static final String TAG = ObjectsJsonSerializer.class.getName(); +public class ObjectJsonSerializer implements JsonSerializer, JsonDeserializer { + private static final String TAG = ObjectJsonSerializer.class.getName(); @Override public Object[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { - ObjectsSerializer serializer = ObjectsHelper.getSerializer(); + ObjectSerializer serializer = ObjectSerializer.tryGet(); if (serializer == null) { - Log.w(TAG, "Skipping 'state' field json deserialization because ObjectsSerializer not found."); + Log.w(TAG, "Skipping 'state' field json deserialization because ObjectSerializer not found."); return null; } if (!json.isJsonArray()) { @@ -29,9 +29,9 @@ public Object[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationC @Override public JsonElement serialize(Object[] src, Type typeOfSrc, JsonSerializationContext context) { - ObjectsSerializer serializer = ObjectsHelper.getSerializer(); + ObjectSerializer serializer = ObjectSerializer.tryGet(); if (serializer == null) { - Log.w(TAG, "Skipping 'state' field json serialization because ObjectsSerializer not found."); + Log.w(TAG, "Skipping 'state' field json serialization because ObjectSerializer not found."); return JsonNull.INSTANCE; } return serializer.asJsonArray(src); diff --git a/lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectSerializer.java b/lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectSerializer.java new file mode 100644 index 000000000..c5b6abcf3 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectSerializer.java @@ -0,0 +1,98 @@ +package io.ably.lib.liveobjects.serialization; + +import com.google.gson.JsonArray; +import io.ably.lib.util.Log; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.msgpack.core.MessagePacker; +import org.msgpack.core.MessageUnpacker; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; + +/** + * Serializer interface for converting between objects and their MessagePack or JSON representations. + */ +public interface ObjectSerializer { + + /** + * Reads a MessagePack array from the given unpacker and deserializes it into an Object array. + * + * @param unpacker the MessageUnpacker to read from + * @return the deserialized Object array + * @throws IOException if an I/O error occurs during unpacking + */ + @NotNull + Object[] readMsgpackArray(@NotNull MessageUnpacker unpacker) throws IOException; + + /** + * Serializes the given Object array as a MessagePack array using the provided packer. + * + * @param objects the Object array to serialize + * @param packer the MessagePacker to write to + * @throws IOException if an I/O error occurs during packing + */ + void writeMsgpackArray(@NotNull Object[] objects, @NotNull MessagePacker packer) throws IOException; + + /** + * Reads a JSON array from the given {@link JsonArray} and deserializes it into an Object array. + * + * @param json the {@link JsonArray} representing the array to deserialize + * @return the deserialized Object array + */ + @NotNull + Object[] readFromJsonArray(@NotNull JsonArray json); + + /** + * Serializes the given Object array as a JSON array. + * + * @param objects the Object array to serialize + * @return the resulting JsonArray + */ + @NotNull + JsonArray asJsonArray(@NotNull Object[] objects); + + /** + * Returns the lazily-initialized, process-wide {@link ObjectSerializer} singleton, reflectively + * loaded from the LiveObjects plugin on the classpath. Returns {@code null} if the plugin is not + * present; the lookup is retried on subsequent calls until it succeeds. + * + * @return the shared {@link ObjectSerializer} instance, or {@code null} if the plugin is unavailable. + */ + @Nullable + static ObjectSerializer tryGet() { + return Holder.getSerializer(); + } + + /** + * Holds the lazily-initialized {@link ObjectSerializer} singleton. Interfaces cannot declare + * mutable static fields, so the cache lives here while {@link #tryGet()} delegates to it. + */ + final class Holder { + private static final String TAG = ObjectSerializer.Holder.class.getName(); + private static final String IMPLEMENTATION_CLASS = "io.ably.lib.liveobjects.serialization.DefaultObjectsSerializer"; + private static volatile ObjectSerializer objectsSerializer; + + private Holder() {} + + @Nullable + static ObjectSerializer getSerializer() { + if (objectsSerializer == null) { + synchronized (Holder.class) { + if (objectsSerializer == null) { // Double-Checked Locking (DCL) + try { + Class serializerClass = Class.forName(IMPLEMENTATION_CLASS); + objectsSerializer = (ObjectSerializer) serializerClass.getDeclaredConstructor().newInstance(); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | + NoSuchMethodException | + InvocationTargetException e) { + Log.w(TAG, "Failed to init ObjectSerializer, LiveObjects plugin not included in the classpath", e); + return null; + } + } + } + } + return objectsSerializer; + } + } +} diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java b/lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateChange.java similarity index 75% rename from lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java rename to lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateChange.java index 180645f3c..d6bb65385 100644 --- a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateChange.java @@ -1,10 +1,10 @@ -package io.ably.lib.objects.state; +package io.ably.lib.liveobjects.state; -import io.ably.lib.objects.ObjectsSubscription; +import io.ably.lib.liveobjects.Subscription; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; -public interface ObjectsStateChange { +public interface ObjectStateChange { /** * Subscribes to a specific Objects synchronization state event. * @@ -17,7 +17,7 @@ public interface ObjectsStateChange { * @return a subscription object that can be used to unsubscribe from the event */ @NonBlocking - ObjectsSubscription on(@NotNull ObjectsStateEvent event, @NotNull ObjectsStateChange.Listener listener); + Subscription on(@NotNull ObjectStateEvent event, @NotNull ObjectStateChange.Listener listener); /** * Unsubscribes the specified listener from all synchronization state events. @@ -28,7 +28,7 @@ public interface ObjectsStateChange { * @param listener the listener to unregister from all events */ @NonBlocking - void off(@NotNull ObjectsStateChange.Listener listener); + void off(@NotNull ObjectStateChange.Listener listener); /** * Unsubscribes all listeners from all synchronization state events. @@ -42,15 +42,15 @@ public interface ObjectsStateChange { /** * Interface for receiving notifications about Objects synchronization state changes. *

- * Implement this interface and register it with an ObjectsStateEmitter to be notified + * Implement this interface and register it with an {@code ObjectStateEmitter} to be notified * when synchronization state transitions occur. */ interface Listener { /** * Called when the synchronization state changes. * - * @param objectsStateEvent The new state event (SYNCING or SYNCED) + * @param objectStateEvent The new state event (SYNCING or SYNCED) */ - void onStateChanged(ObjectsStateEvent objectsStateEvent); + void onStateChanged(ObjectStateEvent objectStateEvent); } } diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java b/lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateEvent.java similarity index 71% rename from lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java rename to lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateEvent.java index 1aa27203a..053d26116 100644 --- a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateEvent.java @@ -1,12 +1,12 @@ -package io.ably.lib.objects.state; +package io.ably.lib.liveobjects.state; /** * Represents the synchronization state of Ably Objects. *

* This enum is used to notify listeners about state changes in the synchronization process. - * Clients can register an {@link ObjectsStateChange.Listener} to receive these events. + * Clients can register an {@link ObjectStateChange.Listener} to receive these events. */ -public enum ObjectsStateEvent { +public enum ObjectStateEvent { /** * Indicates that synchronization between local and remote objects is in progress. */ diff --git a/lib/src/main/java/io/ably/lib/liveobjects/value/LiveCounter.java b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveCounter.java new file mode 100644 index 000000000..484c1be15 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveCounter.java @@ -0,0 +1,72 @@ +package io.ably.lib.liveobjects.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.liveobjects.value.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/liveobjects/value/LiveMap.java b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveMap.java new file mode 100644 index 000000000..022622d8b --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveMap.java @@ -0,0 +1,75 @@ +package io.ably.lib.liveobjects.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.liveobjects.value.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/objects/type/map/LiveMapValue.java b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveMapValue.java similarity index 86% rename from lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java rename to lib/src/main/java/io/ably/lib/liveobjects/value/LiveMapValue.java index ccba80330..406e48d02 100644 --- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveMapValue.java @@ -1,14 +1,20 @@ -package io.ably.lib.objects.type.map; +package io.ably.lib.liveobjects.value; import com.google.gson.JsonArray; import com.google.gson.JsonObject; -import io.ably.lib.objects.type.counter.LiveCounter; import org.jetbrains.annotations.NotNull; /** - * Abstract class representing the union type for LiveMap values. - * Provides strict compile-time type safety, implementation is similar to Gson's JsonElement pattern. - * Spec: RTO11a1 - Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap + * 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 { @@ -20,10 +26,6 @@ public abstract class LiveMapValue { @NotNull public abstract Object getValue(); - /** - * Type checking methods with default implementations - */ - /** * Returns true if this LiveMapValue represents a Boolean value. * @@ -67,23 +69,21 @@ public abstract class LiveMapValue { public boolean isJsonObject() { return false; } /** - * Returns true if this LiveMapValue represents a LiveCounter value. + * 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 LiveMap value. + * 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; } - /** - * Getter methods with default implementations that throw exceptions - */ - /** * Gets the Boolean value if this LiveMapValue represents a Boolean. * @@ -150,9 +150,9 @@ public JsonObject getAsJsonObject() { } /** - * Gets the LiveCounter value if this LiveMapValue represents a LiveCounter. + * Gets the {@link LiveCounter} value type if this LiveMapValue represents one. * - * @return the LiveCounter value + * @return the LiveCounter value type * @throws IllegalStateException if this is not a LiveCounter value */ @NotNull @@ -161,9 +161,9 @@ public LiveCounter getAsLiveCounter() { } /** - * Gets the LiveMap value if this LiveMapValue represents a LiveMap. + * Gets the {@link LiveMap} value type if this LiveMapValue represents one. * - * @return the LiveMap value + * @return the LiveMap value type * @throws IllegalStateException if this is not a LiveMap value */ @NotNull @@ -171,10 +171,6 @@ public LiveMap getAsLiveMap() { throw new IllegalStateException("Not a LiveMap value"); } - /** - * Static factory methods similar to JsonElement constructors - */ - /** * Creates a LiveMapValue from a Boolean. * @@ -187,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 @@ -242,9 +239,9 @@ public static LiveMapValue of(@NotNull JsonObject value) { } /** - * Creates a LiveMapValue from a LiveCounter. + * Creates a LiveMapValue from a new {@link LiveCounter} value type. * - * @param value the LiveCounter value + * @param value the LiveCounter value type * @return a LiveMapValue containing the LiveCounter */ @NotNull @@ -253,9 +250,9 @@ public static LiveMapValue of(@NotNull LiveCounter value) { } /** - * Creates a LiveMapValue from a LiveMap. + * Creates a LiveMapValue from a new {@link LiveMap} value type. * - * @param value the LiveMap value + * @param value the LiveMap value type * @return a LiveMapValue containing the LiveMap */ @NotNull @@ -294,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(); } } /** diff --git a/lib/src/main/java/io/ably/lib/liveobjects/value/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/value/package-info.java new file mode 100644 index 000000000..6a4e798ec --- /dev/null +++ b/lib/src/main/java/io/ably/lib/liveobjects/value/package-info.java @@ -0,0 +1,16 @@ +/** + * Write-side value types for LiveObjects mutations. + * {@link io.ably.lib.liveobjects.value.LiveMapValue} is the union of values + * assignable to a {@code LiveMap} key; + * {@link io.ably.lib.liveobjects.value.LiveMap} and + * {@link io.ably.lib.liveobjects.value.LiveCounter} are immutable initial-value + * holders describing new objects to be created by a mutation; they expose only + * the static {@code create} factories (RTLMV3 / RTLCV3), which delegate to the + * LiveObjects implementation extending these abstract classes. Their internal + * 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.liveobjects.value; diff --git a/lib/src/main/java/io/ably/lib/objects/Adapter.java b/lib/src/main/java/io/ably/lib/objects/Adapter.java deleted file mode 100644 index 76c35cc37..000000000 --- a/lib/src/main/java/io/ably/lib/objects/Adapter.java +++ /dev/null @@ -1,45 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.realtime.AblyRealtime; -import io.ably.lib.realtime.ChannelBase; -import io.ably.lib.realtime.Connection; -import io.ably.lib.types.AblyException; -import io.ably.lib.types.ClientOptions; -import io.ably.lib.types.ErrorInfo; -import io.ably.lib.util.Log; -import org.jetbrains.annotations.NotNull; - -public class Adapter implements ObjectsAdapter { - private final AblyRealtime ably; - private static final String TAG = ObjectsAdapter.class.getName(); - - public Adapter(@NotNull AblyRealtime ably) { - this.ably = ably; - } - - @Override - public @NotNull ClientOptions getClientOptions() { - return ably.options; - } - - @Override - public @NotNull Connection getConnection() { - return ably.connection; - } - - @Override - public long getTime() throws AblyException { - return ably.time(); - } - - @Override - public @NotNull ChannelBase getChannel(@NotNull String channelName) throws AblyException { - if (ably.channels.containsKey(channelName)) { - return ably.channels.get(channelName); - } else { - Log.e(TAG, "attachChannel(): channel not found: " + channelName); - ErrorInfo errorInfo = new ErrorInfo("Channel not found: " + channelName, 404); - throw AblyException.fromErrorInfo(errorInfo); - } - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java b/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java deleted file mode 100644 index 1f34cafdd..000000000 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java +++ /dev/null @@ -1,60 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.realtime.ChannelState; -import io.ably.lib.types.ProtocolMessage; -import org.jetbrains.annotations.NotNull; - -/** - * The LiveObjectsPlugin interface provides a mechanism for managing and interacting with - * live data objects in a real-time environment. It allows for the retrieval, disposal, and - * management of Objects instances associated with specific channel names. - */ -public interface LiveObjectsPlugin { - - /** - * Retrieves an instance of RealtimeObjects associated with the specified channel name. - * This method ensures that a RealtimeObjects instance is available for the given channel, - * creating one if it does not already exist. - * - * @param channelName the name of the channel for which the RealtimeObjects instance is to be retrieved. - * @return the RealtimeObjects instance associated with the specified channel name. - */ - @NotNull - RealtimeObjects getInstance(@NotNull String channelName); - - /** - * Handles a protocol message. - * This method is invoked whenever a protocol message is received, allowing the implementation - * to process the message and take appropriate actions. - * - * @param message the protocol message to handle. - */ - void handle(@NotNull ProtocolMessage message); - - /** - * Handles state changes for a specific channel. - * This method is invoked whenever a channel's state changes, allowing the implementation - * to update the RealtimeObjects instances accordingly based on the new state and presence of objects. - * - * @param channelName the name of the channel whose state has changed. - * @param state the new state of the channel. - * @param hasObjects flag indicates whether the channel has any associated objects. - */ - void handleStateChange(@NotNull String channelName, @NotNull ChannelState state, boolean hasObjects); - - /** - * Disposes of the RealtimeObjects instance associated with the specified channel name. - * This method removes the RealtimeObjects instance for the given channel, releasing any - * resources associated with it. - * This is invoked when ablyRealtimeClient.channels.release(channelName) is called - * - * @param channelName the name of the channel whose RealtimeObjects instance is to be removed. - */ - void dispose(@NotNull String channelName); - - /** - * Disposes of the plugin instance and all underlying resources. - * This is invoked when ablyRealtimeClient.close() is called - */ - void dispose(); -} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java b/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java deleted file mode 100644 index 0afd5ef2f..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java +++ /dev/null @@ -1,31 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.types.AblyException; - -/** - * Callback interface for handling results of asynchronous Objects operations. - * Used for operations like creating LiveMaps/LiveCounters, modifying entries, and retrieving objects. - * Callbacks are executed on background threads managed by the Objects system. - * - * @param the type of the result returned by the asynchronous operation - */ -public interface ObjectsCallback { - - /** - * Called when the asynchronous operation completes successfully. - * For modification operations (set, remove, increment), result is typically Void. - * For creation/retrieval operations, result contains the created/retrieved object. - * - * @param result the result of the operation, may be null for modification operations - */ - void onSuccess(T result); - - /** - * Called when the asynchronous operation fails. - * The exception contains detailed error information including error codes and messages. - * Common errors include network issues, authentication failures, and validation errors. - * - * @param exception the exception that occurred during the operation - */ - void onError(AblyException exception); -} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java b/lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java deleted file mode 100644 index 81e7f3c08..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java +++ /dev/null @@ -1,48 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.realtime.AblyRealtime; -import io.ably.lib.util.Log; -import org.jetbrains.annotations.Nullable; - -import java.lang.reflect.InvocationTargetException; - -public class ObjectsHelper { - - private static final String TAG = ObjectsHelper.class.getName(); - private static volatile ObjectsSerializer objectsSerializer; - - @Nullable - public static LiveObjectsPlugin tryInitializeObjectsPlugin(AblyRealtime ablyRealtime) { - try { - Class objectsImplementation = Class.forName("io.ably.lib.objects.DefaultLiveObjectsPlugin"); - ObjectsAdapter adapter = new Adapter(ablyRealtime); - return (LiveObjectsPlugin) objectsImplementation - .getDeclaredConstructor(ObjectsAdapter.class) - .newInstance(adapter); - } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | - InvocationTargetException e) { - Log.i(TAG, "LiveObjects plugin not found in classpath. LiveObjects functionality will not be available.", e); - return null; - } - } - - @Nullable - public static ObjectsSerializer getSerializer() { - if (objectsSerializer == null) { - synchronized (ObjectsHelper.class) { - if (objectsSerializer == null) { // Double-Checked Locking (DCL) - try { - Class serializerClass = Class.forName("io.ably.lib.objects.serialization.DefaultObjectsSerializer"); - objectsSerializer = (ObjectsSerializer) serializerClass.getDeclaredConstructor().newInstance(); - } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | - NoSuchMethodException | - InvocationTargetException e) { - Log.w(TAG, "Failed to init ObjectsSerializer, LiveObjects plugin not included in the classpath", e); - return null; - } - } - } - } - return objectsSerializer; - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java b/lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java deleted file mode 100644 index 9bee9a8fd..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java +++ /dev/null @@ -1,50 +0,0 @@ -package io.ably.lib.objects; - -import com.google.gson.JsonArray; -import org.jetbrains.annotations.NotNull; -import org.msgpack.core.MessagePacker; -import org.msgpack.core.MessageUnpacker; - -import java.io.IOException; - -/** - * Serializer interface for converting between objects and their MessagePack or JSON representations. - */ -public interface ObjectsSerializer { - /** - * Reads a MessagePack array from the given unpacker and deserializes it into an Object array. - * - * @param unpacker the MessageUnpacker to read from - * @return the deserialized Object array - * @throws IOException if an I/O error occurs during unpacking - */ - @NotNull - Object[] readMsgpackArray(@NotNull MessageUnpacker unpacker) throws IOException; - - /** - * Serializes the given Object array as a MessagePack array using the provided packer. - * - * @param objects the Object array to serialize - * @param packer the MessagePacker to write to - * @throws IOException if an I/O error occurs during packing - */ - void writeMsgpackArray(@NotNull Object[] objects, @NotNull MessagePacker packer) throws IOException; - - /** - * Reads a JSON array from the given {@link JsonArray} and deserializes it into an Object array. - * - * @param json the {@link JsonArray} representing the array to deserialize - * @return the deserialized Object array - */ - @NotNull - Object[] readFromJsonArray(@NotNull JsonArray json); - - /** - * Serializes the given Object array as a JSON array. - * - * @param objects the Object array to serialize - * @return the resulting JsonArray - */ - @NotNull - JsonArray asJsonArray(@NotNull Object[] objects); -} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java b/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java deleted file mode 100644 index 2b22d71d4..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java +++ /dev/null @@ -1,24 +0,0 @@ -package io.ably.lib.objects; - -/** - * Represents a objects subscription that can be unsubscribed from. - * This interface provides a way to clean up and remove subscriptions when they are no longer needed. - * Example usage: - *

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

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

Example:

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

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

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

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

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

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

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

- * This enum notifies listeners about significant lifecycle changes that occur to an Object during its lifetime. - * Clients can register a {@link ObjectLifecycleChange.Listener} to receive these events. - */ -public enum ObjectLifecycleEvent { - /** - * Indicates that an Object has been deleted (tombstoned). - * Emitted once when the object is tombstoned server-side (i.e., deleted and no longer addressable). - * Not re-emitted during client-side garbage collection of tombstones. - */ - DELETED -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java b/lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java deleted file mode 100644 index 8ee1e1578..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.ably.lib.objects.type; - -import org.jetbrains.annotations.Nullable; - -/** - * Abstract base class for all LiveMap/LiveCounter update notifications. - * Provides common structure for updates that occur on LiveMap and LiveCounter objects. - * Contains the update data that describes what changed in the object. - * Spec: RTLO4b4 - */ -public abstract class ObjectUpdate { - /** - * The update data containing details about the change that occurred - * Spec: RTLO4b4a - */ - @Nullable - protected final Object update; - - /** - * Creates a ObjectUpdate with the specified update data. - * - * @param update the data describing the change, or null for no-op updates - */ - protected ObjectUpdate(@Nullable Object update) { - this.update = update; - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java deleted file mode 100644 index 958cf05b1..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java +++ /dev/null @@ -1,73 +0,0 @@ -package io.ably.lib.objects.type.counter; - -import io.ably.lib.objects.ObjectsCallback; -import io.ably.lib.objects.type.ObjectLifecycleChange; -import org.jetbrains.annotations.Blocking; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Contract; - -/** - * The LiveCounter interface provides methods to interact with a live counter. - * It allows incrementing, decrementing, and retrieving the current value of the counter, - * both synchronously and asynchronously. - */ -public interface LiveCounter extends LiveCounterChange, ObjectLifecycleChange { - - /** - * Increments the value of the counter by the specified amount. - * Send a COUNTER_INC operation to the realtime system to increment a value on this LiveCounter object. - * This does not modify the underlying data of this LiveCounter object. Instead, the change will be applied when - * the published COUNTER_INC operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLC12 - * - * @param amount the amount by which to increment the counter - */ - @Blocking - void increment(@NotNull Number amount); - - /** - * Decrements the value of the counter by the specified amount. - * An alias for calling {@link LiveCounter#increment(Number)} with a negative amount. - * Spec: RTLC13 - * - * @param amount the amount by which to decrement the counter - */ - @Blocking - void decrement(@NotNull Number amount); - - /** - * Increments the value of the counter by the specified amount asynchronously. - * Send a COUNTER_INC operation to the realtime system to increment a value on this LiveCounter object. - * This does not modify the underlying data of this LiveCounter object. Instead, the change will be applied when - * the published COUNTER_INC operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLC12 - * - * @param amount the amount by which to increment the counter - * @param callback the callback to be invoked upon completion of the operation. - */ - @NonBlocking - void incrementAsync(@NotNull Number amount, @NotNull ObjectsCallback callback); - - /** - * Decrements the value of the counter by the specified amount asynchronously. - * An alias for calling {@link LiveCounter#incrementAsync(Number, ObjectsCallback)} with a negative amount. - * Spec: RTLC13 - * - * @param amount the amount by which to decrement the counter - * @param callback the callback to be invoked upon completion of the operation. - */ - @NonBlocking - void decrementAsync(@NotNull Number amount, @NotNull ObjectsCallback callback); - - /** - * Retrieves the current value of the counter. - * - * @return the current value of the counter as a Double. - */ - @NotNull - @Contract(pure = true) // Indicates this method does not modify the state of the object. - Double value(); -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java deleted file mode 100644 index 79f842e74..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.ably.lib.objects.type.counter; - -import io.ably.lib.objects.ObjectsSubscription; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.NotNull; - -/** - * Provides methods to subscribe to real-time updates on LiveCounter objects. - * Enables clients to receive notifications when counter values change due to - * operations performed by any client connected to the same channel. - */ -public interface LiveCounterChange { - - /** - * Subscribes to real-time updates on this LiveCounter object. - * Multiple listeners can be subscribed to the same object independently. - * Spec: RTLO4b - * - * @param listener the listener to be notified of counter updates - * @return an ObjectsSubscription for managing this specific listener - */ - @NonBlocking - @NotNull ObjectsSubscription subscribe(@NotNull Listener listener); - - /** - * Unsubscribes a specific listener from receiving updates. - * Has no effect if the listener is not currently subscribed. - * Spec: RTLO4c - * - * @param listener the listener to be unsubscribed - */ - @NonBlocking - void unsubscribe(@NotNull Listener listener); - - /** - * Unsubscribes all listeners from receiving updates. - * No notifications will be delivered until new listeners are subscribed. - * Spec: RTLO4d - */ - @NonBlocking - void unsubscribeAll(); - - /** - * Listener interface for receiving LiveCounter updates. - * Spec: RTLO4b3 - */ - interface Listener { - /** - * Called when the LiveCounter has been updated. - * Should execute quickly as it's called from the real-time processing thread. - * - * @param update details about the counter change - */ - void onUpdated(@NotNull LiveCounterUpdate update); - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java deleted file mode 100644 index d7921a0b5..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java +++ /dev/null @@ -1,80 +0,0 @@ -package io.ably.lib.objects.type.counter; - -import io.ably.lib.objects.type.ObjectUpdate; -import org.jetbrains.annotations.NotNull; - -/** - * Represents an update that occurred on a LiveCounter object. - * Contains information about counter value changes from increment/decrement operations. - * Updates can represent positive changes (increments) or negative changes (decrements). - * - * @spec RTLC11, RTLC11a - LiveCounter update structure and behavior - */ -public class LiveCounterUpdate extends ObjectUpdate { - - /** - * Creates a no-op LiveCounterUpdate representing no actual change. - */ - public LiveCounterUpdate() { - super(null); - } - - /** - * Creates a LiveCounterUpdate with the specified amount change. - * - * @param amount the amount by which the counter changed (positive = increment, negative = decrement) - */ - public LiveCounterUpdate(@NotNull Double amount) { - super(new Update(amount)); - } - - /** - * Gets the update information containing the amount of change. - * - * @return the Update object with the counter modification amount - */ - @NotNull - public LiveCounterUpdate.Update getUpdate() { - return (Update) update; - } - - /** - * Returns a string representation of this LiveCounterUpdate. - * - * @return a string showing the amount of change to the counter - */ - @Override - public String toString() { - if (update == null) { - return "LiveCounterUpdate{no change}"; - } - return "LiveCounterUpdate{amount=" + getUpdate().getAmount() + "}"; - } - - /** - * Contains the specific details of a counter update operation. - * - * @spec RTLC11b, RTLC11b1 - Counter update data structure - */ - public static class Update { - private final @NotNull Double amount; - - /** - * Creates an Update with the specified amount. - * - * @param amount the counter change amount (positive = increment, negative = decrement) - */ - public Update(@NotNull Double amount) { - this.amount = amount; - } - - /** - * Gets the amount by which the counter value was modified. - * - * @return the change amount (positive for increments, negative for decrements) - */ - public @NotNull Double getAmount() { - return amount; - } - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java deleted file mode 100644 index f180fe168..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java +++ /dev/null @@ -1,131 +0,0 @@ -package io.ably.lib.objects.type.map; - -import io.ably.lib.objects.ObjectsCallback; -import io.ably.lib.objects.type.ObjectLifecycleChange; -import org.jetbrains.annotations.Blocking; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.Contract; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.Unmodifiable; - -import java.util.Map; - -/** - * The LiveMap interface provides methods to interact with a live, real-time map structure. - * It supports both synchronous and asynchronous operations for managing key-value pairs. - */ -public interface LiveMap extends LiveMapChange, ObjectLifecycleChange { - - /** - * Retrieves the value associated with the specified key. - * If this map object is tombstoned (deleted), null is returned. - * If no entry is associated with the specified key, null is returned. - * If map entry is tombstoned (deleted), null is returned. - * If the value associated with the provided key is an objectId string of another RealtimeObject, a reference to - * that RealtimeObject is returned, provided it exists in the local pool and is not tombstoned. Otherwise, null is returned. - * If the value is not an objectId, then that value is returned. - * Spec: RTLM5, RTLM5a - * - * @param keyName the key whose associated value is to be returned. - * @return the value associated with the specified key, or null if the key does not exist. - */ - @Nullable - LiveMapValue get(@NotNull String keyName); - - /** - * Retrieves all entries (key-value pairs) in the map. - * Spec: RTLM11, RTLM11a - * - * @return an iterable collection of all entries in the map. - */ - @NotNull - @Unmodifiable - Iterable> entries(); - - /** - * Retrieves all keys in the map. - * Spec: RTLM12, RTLM12a - * - * @return an iterable collection of all keys in the map. - */ - @NotNull - @Unmodifiable - Iterable keys(); - - /** - * Retrieves all values in the map. - * Spec: RTLM13, RTLM13a - * - * @return an iterable collection of all values in the map. - */ - @NotNull - @Unmodifiable - Iterable values(); - - /** - * Sets the specified key to the given value in the map. - * Send a MAP_SET operation to the realtime system to set a key on this LiveMap object to a specified value. - * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when - * the published MAP_SET operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLM20 - * - * @param keyName the key to be set. - * @param value the value to be associated with the key. - */ - @Blocking - void set(@NotNull String keyName, @NotNull LiveMapValue value); - - /** - * Removes the specified key and its associated value from the map. - * Send a MAP_REMOVE operation to the realtime system to tombstone a key on this LiveMap object. - * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when - * the published MAP_REMOVE operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLM21 - * - * @param keyName the key to be removed. - */ - @Blocking - void remove(@NotNull String keyName); - - /** - * Retrieves the number of entries in the map. - * Spec: RTLM10, RTLM10a - * - * @return the size of the map. - */ - @Contract(pure = true) // Indicates this method does not modify the state of the object. - @NotNull - Long size(); - - /** - * Asynchronously sets the specified key to the given value in the map. - * Send a MAP_SET operation to the realtime system to set a key on this LiveMap object to a specified value. - * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when - * the published MAP_SET operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLM20 - * - * @param keyName the key to be set. - * @param value the value to be associated with the key. - * @param callback the callback to handle the result or any errors. - */ - @NonBlocking - void setAsync(@NotNull String keyName, @NotNull LiveMapValue value, @NotNull ObjectsCallback callback); - - /** - * Asynchronously removes the specified key and its associated value from the map. - * Send a MAP_REMOVE operation to the realtime system to tombstone a key on this LiveMap object. - * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when - * the published MAP_REMOVE operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLM21 - * - * @param keyName the key to be removed. - * @param callback the callback to handle the result or any errors. - */ - @NonBlocking - void removeAsync(@NotNull String keyName, @NotNull ObjectsCallback callback); -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java deleted file mode 100644 index c30ae7850..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.ably.lib.objects.type.map; - -import io.ably.lib.objects.ObjectsSubscription; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.NotNull; - -/** - * Provides methods to subscribe to real-time updates on LiveMap objects. - * Enables clients to receive notifications when map entries are added, updated, or removed. - * Uses last-write-wins conflict resolution when multiple clients modify the same key. - */ -public interface LiveMapChange { - - /** - * Subscribes to real-time updates on this LiveMap object. - * Multiple listeners can be subscribed to the same object independently. - * Spec: RTLO4b - * - * @param listener the listener to be notified of map updates - * @return an ObjectsSubscription for managing this specific listener - */ - @NonBlocking - @NotNull ObjectsSubscription subscribe(@NotNull Listener listener); - - /** - * Unsubscribes a specific listener from receiving updates. - * Has no effect if the listener is not currently subscribed. - * Spec: RTLO4c - * - * @param listener the listener to be unsubscribed - */ - @NonBlocking - void unsubscribe(@NotNull Listener listener); - - /** - * Unsubscribes all listeners from receiving updates. - * No notifications will be delivered until new listeners are subscribed. - * Spec: RTLO4d - */ - @NonBlocking - void unsubscribeAll(); - - /** - * Listener interface for receiving LiveMap updates. - * Spec: RTLO4b3 - */ - interface Listener { - /** - * Called when the LiveMap has been updated. - * Should execute quickly as it's called from the real-time processing thread. - * - * @param update details about which keys were modified and how - */ - void onUpdated(@NotNull LiveMapUpdate update); - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java deleted file mode 100644 index 08fe2fc39..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java +++ /dev/null @@ -1,66 +0,0 @@ -package io.ably.lib.objects.type.map; - -import io.ably.lib.objects.type.ObjectUpdate; -import org.jetbrains.annotations.NotNull; - -import java.util.Map; - -/** - * Represents an update that occurred on a LiveMap object. - * Contains information about which keys were modified and whether they were updated or removed. - * - * @spec RTLM18, RTLM18a - LiveMap update structure and behavior - */ -public class LiveMapUpdate extends ObjectUpdate { - - /** - * Creates a no-op LiveMapUpdate representing no actual change. - */ - public LiveMapUpdate() { - super(null); - } - - /** - * Creates a LiveMapUpdate with the specified key changes. - * - * @param update map of key names to their change types (UPDATED or REMOVED) - */ - public LiveMapUpdate(@NotNull Map update) { - super(update); - } - - /** - * Gets the map of key changes that occurred in this update. - * - * @return map of key names to their change types - */ - @NotNull - public Map getUpdate() { - return (Map) update; - } - - /** - * Returns a string representation of this LiveMapUpdate. - * - * @return a string showing the map key changes in this update - */ - @Override - public String toString() { - if (update == null) { - return "LiveMapUpdate{no change}"; - } - return "LiveMapUpdate{changes=" + getUpdate() + "}"; - } - - /** - * Indicates the type of change that occurred to a map key. - * - * @spec RTLM18b - Map change types - */ - public enum Change { - /** The key was added or its value was modified */ - UPDATED, - /** The key was removed from the map */ - REMOVED - } -} diff --git a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java index d9053c8d2..b991ed63b 100644 --- a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java +++ b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java @@ -5,8 +5,7 @@ import java.util.List; import java.util.Map; -import io.ably.lib.objects.ObjectsHelper; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.liveobjects.LiveObjectsPlugin; import io.ably.lib.rest.AblyRest; import io.ably.lib.rest.Auth; import io.ably.lib.transport.ConnectionManager; @@ -74,7 +73,7 @@ public AblyRealtime(ClientOptions options) throws AblyException { final InternalChannels channels = new InternalChannels(); this.channels = channels; - liveObjectsPlugin = ObjectsHelper.tryInitializeObjectsPlugin(this); + liveObjectsPlugin = LiveObjectsPlugin.tryInitialize(this); connection = new Connection(this, channels, platformAgentProvider, liveObjectsPlugin); diff --git a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java index a778e3391..fe68a481d 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java +++ b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java @@ -13,8 +13,8 @@ import io.ably.lib.http.Http; import io.ably.lib.http.HttpCore; import io.ably.lib.http.HttpUtils; -import io.ably.lib.objects.RealtimeObjects; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.liveobjects.RealtimeObject; +import io.ably.lib.liveobjects.LiveObjectsPlugin; import io.ably.lib.rest.MessageEditsMixin; import io.ably.lib.rest.RestAnnotations; import io.ably.lib.transport.ConnectionManager; @@ -112,15 +112,7 @@ public abstract class ChannelBase extends EventEmitter') to your dependency tree", 400, 40019) - ); - } - return liveObjectsPlugin.getInstance(name); - } + public RealtimeObject object; public final RealtimeAnnotations annotations; @@ -1695,7 +1687,9 @@ else if(stateChange.current.equals(failureState)) { this.decodingContext = new DecodingContext(); this.liveObjectsPlugin = liveObjectsPlugin; if (liveObjectsPlugin != null) { - liveObjectsPlugin.getInstance(name); // Make objects instance ready to process sync messages + this.object = liveObjectsPlugin.getInstance(name); + } else { + this.object = RealtimeObject.Unavailable.INSTANCE; } this.annotations = new RealtimeAnnotations( this, diff --git a/lib/src/main/java/io/ably/lib/realtime/Connection.java b/lib/src/main/java/io/ably/lib/realtime/Connection.java index 3ba28a434..dde9238d0 100644 --- a/lib/src/main/java/io/ably/lib/realtime/Connection.java +++ b/lib/src/main/java/io/ably/lib/realtime/Connection.java @@ -1,6 +1,6 @@ package io.ably.lib.realtime; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.liveobjects.LiveObjectsPlugin; import io.ably.lib.realtime.ConnectionStateListener.ConnectionStateChange; import io.ably.lib.transport.ConnectionManager; import io.ably.lib.types.AblyException; diff --git a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java index c9985ef61..15f3b31ab 100644 --- a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java +++ b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java @@ -14,7 +14,7 @@ import io.ably.lib.debug.DebugOptions; import io.ably.lib.debug.DebugOptions.RawProtocolListener; import io.ably.lib.http.HttpHelpers; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.liveobjects.LiveObjectsPlugin; import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.Channel; import io.ably.lib.realtime.ChannelState; diff --git a/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java b/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java index e813a21b7..bab089c6e 100644 --- a/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java +++ b/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java @@ -5,9 +5,8 @@ import java.util.Map; import com.google.gson.annotations.JsonAdapter; -import io.ably.lib.objects.ObjectsSerializer; -import io.ably.lib.objects.ObjectsHelper; -import io.ably.lib.objects.ObjectsJsonSerializer; +import io.ably.lib.liveobjects.serialization.ObjectJsonSerializer; +import io.ably.lib.liveobjects.serialization.ObjectSerializer; import org.jetbrains.annotations.Nullable; import org.msgpack.core.MessageFormat; import org.msgpack.core.MessagePacker; @@ -134,7 +133,7 @@ public ProtocolMessage(Action action, String channel) { * This is targeted and specific to the state field, so won't affect other fields */ @Nullable - @JsonAdapter(ObjectsJsonSerializer.class) + @JsonAdapter(ObjectJsonSerializer.class) public Object[] state; public @Nullable PublishResult[] res; @@ -162,7 +161,7 @@ void writeMsgpack(MessagePacker packer) throws IOException { if(params != null) ++fieldCount; if(channelSerial != null) ++fieldCount; if(annotations != null) ++fieldCount; - if(state != null && ObjectsHelper.getSerializer() != null) ++fieldCount; + if(state != null && ObjectSerializer.tryGet() != null) ++fieldCount; if(res != null) ++fieldCount; packer.packMapHeader(fieldCount); packer.packString("action"); @@ -204,7 +203,7 @@ void writeMsgpack(MessagePacker packer) throws IOException { AnnotationSerializer.writeMsgpackArray(annotations, packer); } if(state != null) { - ObjectsSerializer objectsSerializer = ObjectsHelper.getSerializer(); + ObjectSerializer objectsSerializer = ObjectSerializer.tryGet(); if (objectsSerializer != null) { packer.packString("state"); objectsSerializer.writeMsgpackArray(state, packer); @@ -279,7 +278,7 @@ ProtocolMessage readMsgpack(MessageUnpacker unpacker) throws IOException { annotations = AnnotationSerializer.readMsgpackArray(unpacker); break; case "state": - ObjectsSerializer objectsSerializer = ObjectsHelper.getSerializer(); + ObjectSerializer objectsSerializer = ObjectSerializer.tryGet(); if (objectsSerializer != null) { state = objectsSerializer.readMsgpackArray(unpacker); } else { diff --git a/lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java b/lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java index c110c9af3..9ccd005a4 100644 --- a/lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java +++ b/lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java @@ -2,8 +2,8 @@ import com.google.gson.JsonSyntaxException; -import java.util.HashMap; import java.util.Map; +import java.util.TreeMap; import io.ably.lib.util.Log; import io.ably.lib.util.Serialisation; @@ -13,7 +13,8 @@ public class RecoveryKeyContext { private final String connectionKey; private final long msgSerial; - private final Map channelSerials = new HashMap<>(); + // Sorted so encode() produces deterministic, key-ordered JSON regardless of input map ordering. + private final Map channelSerials = new TreeMap<>(); public RecoveryKeyContext(String connectionKey, long msgSerial, Map channelSerials) { this.connectionKey = connectionKey; diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java index 5ff53c6f4..8fa2a3bbc 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java @@ -2644,11 +2644,14 @@ public void channel_get_objects_throws_exception() throws AblyException { new ChannelWaiter(channel).waitFor(ChannelState.attached); assertEquals("Verify attached state reached", channel.state, ChannelState.attached); - AblyException exception = assertThrows(AblyException.class, channel::getObjects); + IllegalStateException exception = assertThrows(IllegalStateException.class, () -> channel.object.get()); assertNotNull(exception); - assertEquals(40019, exception.errorInfo.code); - assertEquals(400, exception.errorInfo.statusCode); - assertTrue(exception.errorInfo.message.contains("LiveObjects plugin hasn't been installed")); + assertTrue(exception.getMessage().contains("LiveObjects plugin hasn't been installed")); + + AblyException cause = (AblyException) exception.getCause(); + assertNotNull(cause); + assertEquals(40019, cause.errorInfo.code); + assertEquals(400, cause.errorInfo.statusCode); } } diff --git a/liveobjects/build.gradle.kts b/liveobjects/build.gradle.kts index 5b45ce92e..9d6ad9420 100644 --- a/liveobjects/build.gradle.kts +++ b/liveobjects/build.gradle.kts @@ -30,17 +30,17 @@ tasks.withType().configureEach { outputs.upToDateWhen { false } } -tasks.register("runLiveObjectUnitTests") { +tasks.register("runLiveObjectsUnitTests") { filter { - includeTestsMatching("io.ably.lib.objects.unit.*") + includeTestsMatching("io.ably.lib.liveobjects.unit.*") } } -tasks.register("runLiveObjectIntegrationTests") { +tasks.register("runLiveObjectsIntegrationTests") { filter { - includeTestsMatching("io.ably.lib.objects.integration.*") + includeTestsMatching("io.ably.lib.liveobjects.integration.*") // Exclude the base integration test class - excludeTestsMatching("io.ably.lib.objects.integration.setup.IntegrationTest") + excludeTestsMatching("io.ably.lib.liveobjects.integration.setup.IntegrationTest") } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt new file mode 100644 index 000000000..ff6dbb070 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt @@ -0,0 +1,41 @@ +package io.ably.lib.liveobjects + +import io.ably.lib.liveobjects.adapter.AblyClientAdapter +import io.ably.lib.liveobjects.path.types.LiveMapPathObject +import io.ably.lib.liveobjects.state.ObjectStateChange +import io.ably.lib.liveobjects.state.ObjectStateEvent +import java.util.concurrent.CompletableFuture + +/** + * Default implementation of [RealtimeObject], the entry point to the strongly-typed, + * path-based LiveObjects API for a single channel. + * + * This is currently a skeleton: the path-based read and subscribe operations are not yet + * implemented. The method bodies will be filled in as the path-based API is built out. + * + * Spec: RTO23 + */ +internal class DefaultRealtimeObject( + internal val channelName: String, + internal val adapter: AblyClientAdapter, +) : RealtimeObject { + + override fun get(): CompletableFuture = TODO("Not yet implemented") + + override fun on(event: ObjectStateEvent, listener: ObjectStateChange.Listener): Subscription { + // TODO - subscribe logic goes here + return onceSubscription { + // TODO - remove ObjectStateChange.Listener + } + } + + override fun off(listener: ObjectStateChange.Listener): Unit = TODO("Not yet implemented") + + override fun offAll(): Unit = TODO("Not yet implemented") + + /** Validates the channel is configured for access (read/subscribe) operations. Spec: RTO25 */ + internal fun throwIfInvalidAccessApiConfiguration() = adapter.throwIfInvalidAccessApiConfiguration(channelName) + + /** Validates the channel is configured for write (mutation) operations. Spec: RTO26 */ + internal fun throwIfInvalidWriteApiConfiguration() = adapter.throwIfInvalidWriteApiConfiguration(channelName) +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Errors.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Errors.kt new file mode 100644 index 000000000..98bd89691 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Errors.kt @@ -0,0 +1,55 @@ +package io.ably.lib.liveobjects + +import io.ably.lib.types.AblyException +import io.ably.lib.types.ErrorInfo + +/** + * Error codes and helpers for the path-based public API implementation. + * Copied (and extended with the path-API codes) from the legacy package so + * this package has no dependency on `io.ably.lib.objects`. + */ +internal enum class ObjectErrorCode(val code: Int) { + BadRequest(40_000), + InternalError(50_000), + MaxMessageSizeExceeded(40_009), + InvalidObject(92_000), + InvalidInputParams(40_003), + MapValueDataTypeUnsupported(40_013), + PathNotResolved(92_005), // RTPO3c2 - write operation on a path that does not resolve + ObjectsTypeMismatch(92_007), // RTTS5d2/RTTS9d2 - operation on a cast wrapper with mismatched resolved type + // Channel mode and state validation error codes + ChannelModeRequired(40_024), + ChannelStateError(90_001), + PublishAndApplyFailedDueToChannelState(92_008), +} + +internal enum class ObjectHttpStatusCode(val code: Int) { + BadRequest(400), + InternalServerError(500), +} + +internal fun objectException( + errorMessage: String, + errorCode: ObjectErrorCode, + statusCode: ObjectHttpStatusCode = ObjectHttpStatusCode.BadRequest, + cause: Throwable? = null, +): AblyException { + val errorInfo = ErrorInfo(errorMessage, statusCode.code, errorCode.code) + return cause?.let { AblyException.fromErrorInfo(it, errorInfo) } ?: AblyException.fromErrorInfo(errorInfo) +} + +/** ErrorInfo 400 / 40003 - invalid input (RTLMV4a/b, RTLCV4a, key validation). */ +internal fun invalidInputError(message: String) = + objectException(message, ObjectErrorCode.InvalidInputParams) + +/** ErrorInfo 400 / 92005 - write operation on an unresolvable path (RTPO3c2). */ +internal fun pathNotResolvedError(path: String) = + objectException("Path could not be resolved: \"$path\"", ObjectErrorCode.PathNotResolved) + +/** ErrorInfo 400 / 92007 - resolved/wrapped type does not match the typed wrapper (RTTS5d2/RTTS9d2). */ +internal fun typeMismatchError(message: String) = + objectException(message, ObjectErrorCode.ObjectsTypeMismatch) + +/** ErrorInfo 500 / 92000 - invalid internal object state. */ +internal fun objectStateError(message: String) = + objectException(message, ObjectErrorCode.InvalidObject, ObjectHttpStatusCode.InternalServerError) diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Helpers.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Helpers.kt new file mode 100644 index 000000000..e8859cc2b --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Helpers.kt @@ -0,0 +1,197 @@ +package io.ably.lib.liveobjects + +import io.ably.lib.liveobjects.adapter.AblyClientAdapter +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.size +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.CompletionListener +import io.ably.lib.realtime.ConnectionEvent +import io.ably.lib.realtime.ConnectionStateListener +import io.ably.lib.types.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Wraps [onUnsubscribe] in a [Subscription] that runs the cleanup at most once; further + * calls are no-ops. Use it wherever a [Subscription] is returned: `EventEmitter.off` is + * `synchronized`, so this avoids re-acquiring that lock (and re-running teardown) on + * repeated unsubscribe calls. Thread-safe. + * + * Spec: SUB2a, SUB2b + */ +internal fun onceSubscription(onUnsubscribe: () -> Unit): Subscription { + val unsubscribed = AtomicBoolean(false) + return Subscription { + if (unsubscribed.compareAndSet(false, true)) { + onUnsubscribe() + } + } +} + +/** + * Validates that the channel is configured for the access (read/subscribe) API: it must be + * attachable (not detached/failed) and have the `object_subscribe` mode. Copied from the + * legacy `io.ably.lib.objects` helpers so this package has no dependency on that package. + * + * Spec: RTO25 + */ +internal fun AblyClientAdapter.throwIfInvalidAccessApiConfiguration(channelName: String) { + throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed)) + throwIfMissingChannelMode(channelName, ChannelMode.object_subscribe) +} + +/** + * Validates that the channel is configured for the write (mutation) API: message echo must be + * enabled, the channel must be usable (not detached/failed/suspended) and have the + * `object_publish` mode. + * + * Spec: RTO26 + */ +internal fun AblyClientAdapter.throwIfInvalidWriteApiConfiguration(channelName: String) { + throwIfEchoMessagesDisabled() + throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed, ChannelState.suspended)) + throwIfMissingChannelMode(channelName, ChannelMode.object_publish) +} + +/** + * Resolves the effective channel modes: the attached `modes` if present, otherwise the + * user-provided channel options as a best effort. + * + * Spec: RTO2a, RTO2b + */ +internal fun AblyClientAdapter.getChannelModes(channelName: String): Array? { + val channel = getChannel(channelName) + channel.modes?.let { modes -> if (modes.isNotEmpty()) return modes } // RTO2a + channel.options?.let { options -> if (options.hasModes()) return options.modes } // RTO2b + return null +} + +// Spec: RTO2a2, RTO2b2 +private fun AblyClientAdapter.throwIfMissingChannelMode(channelName: String, channelMode: ChannelMode) { + val channelModes = getChannelModes(channelName) + if (channelModes == null || !channelModes.contains(channelMode)) { + throw objectException( + "\"${channelMode.name}\" channel mode must be set for this operation", + ObjectErrorCode.ChannelModeRequired, + ) + } +} + +private fun AblyClientAdapter.throwIfInChannelState(channelName: String, channelStates: Array) { + val currentState = getChannel(channelName).state + if (currentState == null || channelStates.contains(currentState)) { + throw objectException("Channel is in invalid state: $currentState", ObjectErrorCode.ChannelStateError) + } +} + +private fun AblyClientAdapter.throwIfEchoMessagesDisabled() { + if (!clientOptions.echoMessages) { + throw objectException( + "\"echoMessages\" client option must be enabled for this operation", + ObjectErrorCode.BadRequest, + ) + } +} + +internal fun AblyClientAdapter.throwIfUnpublishableState(channelName: String) { + if (!connectionManager.isActive) { + throw ablyException(connectionManager.stateErrorInfo) + } + throwIfInChannelState(channelName, arrayOf(ChannelState.failed, ChannelState.suspended)) +} + +internal val AblyClientAdapter.connectionManager get() = connection.connectionManager + +internal fun AblyClientAdapter.onGCGracePeriodUpdated(block : (Long?) -> Unit) : Subscription { + connectionManager.objectsGCGracePeriod?.let { block(it) } + // Return new objectsGCGracePeriod whenever connection state changes to connected + val listener: (_: ConnectionStateListener.ConnectionStateChange) -> Unit = { + block(connectionManager.objectsGCGracePeriod) + } + connection.on(ConnectionEvent.connected, listener) + return onceSubscription { connection.off(listener) } +} + +/** + * Spec: RTO15g + */ +internal suspend fun AblyClientAdapter.sendAsync(message: ProtocolMessage): PublishResult = suspendCancellableCoroutine { continuation -> + try { + connectionManager.send(message, clientOptions.queueMessages, object : Callback { + override fun onSuccess(result: PublishResult) { + continuation.resume(result) + } + + override fun onError(reason: ErrorInfo) { + continuation.resumeWithException(ablyException(reason)) + } + }) + } catch (e: Exception) { + continuation.resumeWithException(e) + } +} + +internal suspend fun AblyClientAdapter.attachAsync(channelName: String) = suspendCancellableCoroutine { continuation -> + try { + getChannel(channelName).attach(object : CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } + + override fun onError(reason: ErrorInfo) { + continuation.resumeWithException(ablyException(reason)) + } + }) + } catch (e: Exception) { + continuation.resumeWithException(e) + } +} + +/** + * Spec: RTO15d + */ +internal fun AblyClientAdapter.ensureMessageSizeWithinLimit(wireObjectMessages: Array) { + val maximumAllowedSize = connectionManager.maxMessageSize + val objectsTotalMessageSize = wireObjectMessages.sumOf { it.size() } + if (objectsTotalMessageSize > maximumAllowedSize) { + throw ablyException("ObjectMessages size $objectsTotalMessageSize exceeds maximum allowed size of $maximumAllowedSize bytes", + ObjectErrorCode.MaxMessageSizeExceeded) + } +} + +internal fun AblyClientAdapter.setChannelSerial(channelName: String, protocolMessage: ProtocolMessage) { + if (protocolMessage.action != ProtocolMessage.Action.`object`) return + val channelSerial = protocolMessage.channelSerial + if (channelSerial.isNullOrEmpty()) return + getChannel(channelName).properties.channelSerial = channelSerial +} + +internal suspend fun AblyClientAdapter.ensureAttached(channelName: String) { + val channel = getChannel(channelName) + when (val currentChannelStatus = channel.state) { + ChannelState.initialized -> attachAsync(channelName) + ChannelState.attached -> return + ChannelState.attaching -> { + val attachDeferred = CompletableDeferred() + getChannel(channelName).once { + when(it.current) { + ChannelState.attached -> attachDeferred.complete(Unit) + else -> { + val exception = ablyException("Channel $channelName is in invalid state: ${it.current}, " + + "error: ${it.reason}", ObjectErrorCode.ChannelStateError) + attachDeferred.completeExceptionally(exception) + } + } + } + if (channel.state == ChannelState.attached) { + attachDeferred.complete(Unit) + } + attachDeferred.await() + } + else -> + throw ablyException("Channel $channelName is in invalid state: $currentChannelStatus", ObjectErrorCode.ChannelStateError) + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Utils.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Utils.kt new file mode 100644 index 000000000..8cc628a32 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Utils.kt @@ -0,0 +1,60 @@ +package io.ably.lib.liveobjects + +import io.ably.lib.types.AblyException +import io.ably.lib.types.ErrorInfo +import java.nio.charset.StandardCharsets + +internal fun ablyException( + errorMessage: String, + errorCode: ObjectErrorCode, + statusCode: ObjectHttpStatusCode = ObjectHttpStatusCode.BadRequest, + cause: Throwable? = null, +): AblyException { + val errorInfo = createErrorInfo(errorMessage, errorCode, statusCode) + return createAblyException(errorInfo, cause) +} + +internal fun ablyException( + errorInfo: ErrorInfo, + cause: Throwable? = null, +): AblyException = createAblyException(errorInfo, cause) + +private fun createErrorInfo( + errorMessage: String, + errorCode: ObjectErrorCode, + statusCode: ObjectHttpStatusCode, +) = ErrorInfo(errorMessage, statusCode.code, errorCode.code) + +private fun createAblyException( + errorInfo: ErrorInfo, + cause: Throwable?, +) = cause?.let { AblyException.fromErrorInfo(it, errorInfo) } + ?: AblyException.fromErrorInfo(errorInfo) + +internal fun clientError(errorMessage: String) = ablyException(errorMessage, ObjectErrorCode.BadRequest, ObjectHttpStatusCode.BadRequest) + +internal fun serverError(errorMessage: String) = ablyException(errorMessage, ObjectErrorCode.InternalError, ObjectHttpStatusCode.InternalServerError) + +internal fun objectError(errorMessage: String, cause: Throwable? = null): AblyException { + return ablyException(errorMessage, ObjectErrorCode.InvalidObject, ObjectHttpStatusCode.InternalServerError, cause) +} + +internal fun invalidInputError(errorMessage: String, cause: Throwable? = null): AblyException { + return ablyException(errorMessage, ObjectErrorCode.InvalidInputParams, ObjectHttpStatusCode.InternalServerError, cause) +} + +/** + * Calculates the byte size of a string. + * For non-ASCII, the byte size can be 2–4x the character count. For ASCII, there is no difference. + * e.g. "Hello" has a byte size of 5, while "你" has a byte size of 3 and "😊" has a byte size of 4. + */ +internal val String.byteSize: Int + get() = this.toByteArray(StandardCharsets.UTF_8).size + +/** + * Generates a random nonce string for object creation. + */ +internal fun generateNonce(): String { + val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" // avoid calculation using range + return (1..16).map { chars.random() }.joinToString("") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstance.kt new file mode 100644 index 000000000..0b710bbca --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstance.kt @@ -0,0 +1,46 @@ +package io.ably.lib.liveobjects.instance + +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.instance.types.BinaryInstance +import io.ably.lib.liveobjects.instance.types.BooleanInstance +import io.ably.lib.liveobjects.instance.types.JsonArrayInstance +import io.ably.lib.liveobjects.instance.types.JsonObjectInstance +import io.ably.lib.liveobjects.instance.types.LiveCounterInstance +import io.ably.lib.liveobjects.instance.types.LiveMapInstance +import io.ably.lib.liveobjects.instance.types.NumberInstance +import io.ably.lib.liveobjects.instance.types.StringInstance + +/** + * Default implementation of [Instance], the identity-addressed node in the LiveObjects graph. + * + * An instance is always bound to a specific resolved value of a known type, so this base is + * abstract: each concrete sub-type supplies [getType] and [compactJson] (left abstract here) + * and overrides only the single `as*` cast matching its own type to return `this`. The + * remaining `as*` casts fall through to the implementations here, which fail fast because the + * wrapped value is not of the requested type. + * + * Only the channel's [channelObject] context is carried; unlike a path object there is no + * parent/child path, since an instance is identity-addressed. + * + * Spec: RTINS1, RTTS7 + */ +internal abstract class DefaultInstance( + internal val channelObject: DefaultRealtimeObject, +) : Instance { + + override fun asLiveMap(): LiveMapInstance = throw IllegalStateException("Not a LiveMap instance") + + override fun asLiveCounter(): LiveCounterInstance = throw IllegalStateException("Not a LiveCounter instance") + + override fun asNumber(): NumberInstance = throw IllegalStateException("Not a Number instance") + + override fun asString(): StringInstance = throw IllegalStateException("Not a String instance") + + override fun asBoolean(): BooleanInstance = throw IllegalStateException("Not a Boolean instance") + + override fun asBinary(): BinaryInstance = throw IllegalStateException("Not a Binary instance") + + override fun asJsonObject(): JsonObjectInstance = throw IllegalStateException("Not a JsonObject instance") + + override fun asJsonArray(): JsonArrayInstance = throw IllegalStateException("Not a JsonArray instance") +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstanceSubscriptionEvent.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstanceSubscriptionEvent.kt new file mode 100644 index 000000000..428a3b88f --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstanceSubscriptionEvent.kt @@ -0,0 +1,20 @@ +package io.ably.lib.liveobjects.instance + +import io.ably.lib.liveobjects.message.ObjectMessage + +/** + * Default implementation of [InstanceSubscriptionEvent], the event delivered to an + * [InstanceListener] when the wrapped LiveObject is updated. A plain holder for the updated + * [Instance] and the source [ObjectMessage] (if any). + * + * Spec: RTINS16e + */ +internal class DefaultInstanceSubscriptionEvent( + private val instance: Instance, + private val message: ObjectMessage?, +) : InstanceSubscriptionEvent { + + override fun getObject(): Instance = instance + + override fun getMessage(): ObjectMessage? = message +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBinaryInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBinaryInstance.kt new file mode 100644 index 000000000..0abf41285 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBinaryInstance.kt @@ -0,0 +1,31 @@ +package io.ably.lib.liveobjects.instance.types + +import com.google.gson.JsonPrimitive +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance + +/** + * Default implementation of [BinaryInstance], a read-only primitive view that only adds a + * type-narrowed, non-null [value]; left unimplemented for now. + * + * Spec: RTTS10c + */ +internal class DefaultBinaryInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), BinaryInstance { + + override fun getType(): ValueType = ValueType.BINARY + + override fun compactJson(): JsonPrimitive { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } + + override fun asBinary(): BinaryInstance = this + + override fun value(): ByteArray { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBooleanInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBooleanInstance.kt new file mode 100644 index 000000000..ab5eeae4d --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBooleanInstance.kt @@ -0,0 +1,31 @@ +package io.ably.lib.liveobjects.instance.types + +import com.google.gson.JsonPrimitive +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance + +/** + * Default implementation of [BooleanInstance], a read-only primitive view that only adds a + * type-narrowed, non-null [value]; left unimplemented for now. + * + * Spec: RTTS10c + */ +internal class DefaultBooleanInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), BooleanInstance { + + override fun getType(): ValueType = ValueType.BOOLEAN + + override fun compactJson(): JsonPrimitive { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } + + override fun asBoolean(): BooleanInstance = this + + override fun value(): Boolean { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonArrayInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonArrayInstance.kt new file mode 100644 index 000000000..ecd755a32 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonArrayInstance.kt @@ -0,0 +1,31 @@ +package io.ably.lib.liveobjects.instance.types + +import com.google.gson.JsonArray +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance + +/** + * Default implementation of [JsonArrayInstance], a read-only primitive view that only adds + * a type-narrowed, non-null [value]; left unimplemented for now. + * + * Spec: RTTS10c + */ +internal class DefaultJsonArrayInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), JsonArrayInstance { + + override fun getType(): ValueType = ValueType.JSON_ARRAY + + override fun compactJson(): JsonArray { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } + + override fun asJsonArray(): JsonArrayInstance = this + + override fun value(): JsonArray { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonObjectInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonObjectInstance.kt new file mode 100644 index 000000000..3ce012fd6 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonObjectInstance.kt @@ -0,0 +1,31 @@ +package io.ably.lib.liveobjects.instance.types + +import com.google.gson.JsonObject +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance + +/** + * Default implementation of [JsonObjectInstance], a read-only primitive view that only adds + * a type-narrowed, non-null [value]; left unimplemented for now. + * + * Spec: RTTS10c + */ +internal class DefaultJsonObjectInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), JsonObjectInstance { + + override fun getType(): ValueType = ValueType.JSON_OBJECT + + override fun compactJson(): JsonObject { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } + + override fun asJsonObject(): JsonObjectInstance = this + + override fun value(): JsonObject { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveCounterInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveCounterInstance.kt new file mode 100644 index 000000000..60aae7cc4 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveCounterInstance.kt @@ -0,0 +1,65 @@ +package io.ably.lib.liveobjects.instance.types + +import com.google.gson.JsonPrimitive +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.Subscription +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance +import io.ably.lib.liveobjects.instance.InstanceListener +import io.ably.lib.liveobjects.onceSubscription +import java.util.concurrent.CompletableFuture + +/** + * Default implementation of [LiveCounterInstance], adding counter operations and subscribe + * on top of [DefaultInstance]; all left unimplemented for now. + * + * Spec: RTTS10b + */ +internal class DefaultLiveCounterInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), LiveCounterInstance { + + override fun getType(): ValueType = ValueType.LIVE_COUNTER + + override fun compactJson(): JsonPrimitive { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } + + override fun asLiveCounter(): LiveCounterInstance = this + + override fun getId(): String = TODO("Not yet implemented") + + override fun value(): Double { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } + + override fun increment(): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + TODO("Not yet implemented") + } + + override fun increment(amount: Number): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + TODO("Not yet implemented") + } + + override fun decrement(): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + TODO("Not yet implemented") + } + + override fun decrement(amount: Number): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + TODO("Not yet implemented") + } + + override fun subscribe(listener: InstanceListener): Subscription { + channelObject.throwIfInvalidAccessApiConfiguration() + // TODO - subscribe logic goes here + return onceSubscription { + // TODO - remove InstanceListener + } + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveMapInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveMapInstance.kt new file mode 100644 index 000000000..22d9c3f10 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveMapInstance.kt @@ -0,0 +1,78 @@ +package io.ably.lib.liveobjects.instance.types + +import com.google.gson.JsonObject +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.Subscription +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance +import io.ably.lib.liveobjects.instance.Instance +import io.ably.lib.liveobjects.instance.InstanceListener +import io.ably.lib.liveobjects.onceSubscription +import io.ably.lib.liveobjects.value.LiveMapValue +import java.util.concurrent.CompletableFuture + +/** + * Default implementation of [LiveMapInstance], adding map reads, writes and subscribe on top + * of [DefaultInstance]; all left unimplemented for now. + * + * Spec: RTTS10a + */ +internal class DefaultLiveMapInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), LiveMapInstance { + + override fun getType(): ValueType = ValueType.LIVE_MAP + + override fun compactJson(): JsonObject { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } + + override fun asLiveMap(): LiveMapInstance = this + + override fun getId(): String = TODO("Not yet implemented") + + @Suppress("RedundantNullableReturnType") + override fun get(key: String): Instance? { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } + + override fun entries(): Iterable> { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } + + override fun keys(): Iterable { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } + + override fun values(): Iterable { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } + + override fun size(): Long { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } + + override fun set(key: String, value: LiveMapValue): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + TODO("Not yet implemented") + } + + override fun remove(key: String): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + TODO("Not yet implemented") + } + + override fun subscribe(listener: InstanceListener): Subscription { + channelObject.throwIfInvalidAccessApiConfiguration() + // TODO - subscribe logic goes here + return onceSubscription { + // TODO - remove InstanceListener + } + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultNumberInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultNumberInstance.kt new file mode 100644 index 000000000..27910de7b --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultNumberInstance.kt @@ -0,0 +1,31 @@ +package io.ably.lib.liveobjects.instance.types + +import com.google.gson.JsonPrimitive +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance + +/** + * Default implementation of [NumberInstance], a read-only primitive view that only adds a + * type-narrowed, non-null [value]; left unimplemented for now. + * + * Spec: RTTS10c + */ +internal class DefaultNumberInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), NumberInstance { + + override fun getType(): ValueType = ValueType.NUMBER + + override fun compactJson(): JsonPrimitive { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } + + override fun asNumber(): NumberInstance = this + + override fun value(): Number { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultStringInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultStringInstance.kt new file mode 100644 index 000000000..998608e9b --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultStringInstance.kt @@ -0,0 +1,31 @@ +package io.ably.lib.liveobjects.instance.types + +import com.google.gson.JsonPrimitive +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.DefaultInstance + +/** + * Default implementation of [StringInstance], a read-only primitive view that only adds a + * type-narrowed, non-null [value]; left unimplemented for now. + * + * Spec: RTTS10c + */ +internal class DefaultStringInstance( + channelObject: DefaultRealtimeObject, +) : DefaultInstance(channelObject), StringInstance { + + override fun getType(): ValueType = ValueType.STRING + + override fun compactJson(): JsonPrimitive { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } + + override fun asString(): StringInstance = this + + override fun value(): String { + channelObject.throwIfInvalidAccessApiConfiguration() + TODO("Not yet implemented") + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/DefaultObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/DefaultObjectMessage.kt new file mode 100644 index 000000000..d206b37fe --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/DefaultObjectMessage.kt @@ -0,0 +1,146 @@ +package io.ably.lib.liveobjects.message + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import io.ably.lib.liveobjects.objectStateError +import java.util.* + +/** + * Builds the user-facing PublicAPI::ObjectMessage from an inbound wire + * ObjectMessage that carried an operation. Mirrors ably-js + * `objectmessage.ts#toUserFacingMessage`. + * + * Precondition (PAOM3a1): the source message has its `operation` populated. + * + * Spec: PAOM3 + */ +internal fun WireObjectMessage.toPublicMessage(channelName: String): ObjectMessage = + DefaultObjectMessage(this, channelName) + +/** + * PublicAPI::ObjectMessage implementation - a read-only view over the source + * wire message. Spec: PAOM1, PAOM2 + */ +internal class DefaultObjectMessage( + private val message: WireObjectMessage, + private val channelName: String, +) : ObjectMessage { + + private val operation: ObjectOperation = DefaultObjectOperation( + message.operation ?: throw objectStateError("Cannot build public ObjectMessage without an operation") // PAOM3a1 + ) + + override fun getId(): String? = message.id // PAOM2a + override fun getClientId(): String? = message.clientId // PAOM2b + override fun getConnectionId(): String? = message.connectionId // PAOM2c + override fun getTimestamp(): Long? = message.timestamp // PAOM2d + override fun getChannel(): String = channelName // PAOM2e, PAOM3b + override fun getOperation(): ObjectOperation = operation // PAOM2f + override fun getSerial(): String? = message.serial // PAOM2g + override fun getSerialTimestamp(): Long? = message.serialTimestamp // PAOM2h + override fun getSiteCode(): String? = message.siteCode // PAOM2i + override fun getExtras(): JsonObject? = message.extras // PAOM2j +} + +/** + * PublicAPI::ObjectOperation implementation. Resolves the outbound-only + * `*CreateWithObjectId` variants back to their derived MapCreate/CounterCreate + * forms. Spec: PAOOP1, PAOOP2, PAOOP3 + */ +internal class DefaultObjectOperation(private val operation: WireObjectOperation) : ObjectOperation { + + override fun getAction(): ObjectOperationAction = operation.action.toPublic() // PAOOP2a + + override fun getObjectId(): String = operation.objectId // PAOOP2b + + // PAOOP3b - prefer mapCreate, else the MapCreate the WithObjectId variant was derived from + override fun getMapCreate(): MapCreate? = + (operation.mapCreate ?: operation.mapCreateWithObjectId?.derivedFrom)?.let { DefaultMapCreate(it) } + + override fun getMapSet(): MapSet? = operation.mapSet?.let { DefaultMapSet(it) } // PAOOP2d + + override fun getMapRemove(): MapRemove? = operation.mapRemove?.let { DefaultMapRemove(it) } // PAOOP2e + + // PAOOP3c - prefer counterCreate, else the derived CounterCreate + override fun getCounterCreate(): CounterCreate? = + (operation.counterCreate ?: operation.counterCreateWithObjectId?.derivedFrom)?.let { DefaultCounterCreate(it) } + + override fun getCounterInc(): CounterInc? = operation.counterInc?.let { DefaultCounterInc(it) } // PAOOP2g + + override fun getObjectDelete(): ObjectDelete? = operation.objectDelete?.let { DefaultObjectDelete } // PAOOP2h + + override fun getMapClear(): MapClear? = operation.mapClear?.let { DefaultMapClear } // PAOOP2i +} + +/** Spec: MCR2 */ +internal class DefaultMapCreate(private val mapCreate: WireMapCreate) : MapCreate { + override fun getSemantics(): ObjectsMapSemantics = mapCreate.semantics.toPublic() + override fun getEntries(): Map = + Collections.unmodifiableMap(mapCreate.entries.mapValues { (_, entry) -> DefaultObjectsMapEntry(entry) }) +} + +/** Spec: MST2 */ +internal class DefaultMapSet(private val mapSet: WireMapSet) : MapSet { + override fun getKey(): String = mapSet.key + override fun getValue(): ObjectData = DefaultObjectData(mapSet.value) +} + +/** Spec: MRM2 */ +internal class DefaultMapRemove(private val mapRemove: WireMapRemove) : MapRemove { + override fun getKey(): String = mapRemove.key +} + +/** Spec: CCR2 */ +internal class DefaultCounterCreate(private val counterCreate: WireCounterCreate) : CounterCreate { + override fun getCount(): Double = counterCreate.count +} + +/** Spec: CIN2 */ +internal class DefaultCounterInc(private val counterInc: WireCounterInc) : CounterInc { + override fun getNumber(): Double = counterInc.number +} + +/** Spec: ODE2 - no attributes */ +internal object DefaultObjectDelete : ObjectDelete + +/** Spec: MCL2 - no attributes */ +internal object DefaultMapClear : MapClear + +/** Spec: OME2 */ +internal class DefaultObjectsMapEntry(private val entry: WireObjectsMapEntry) : ObjectsMapEntry { + override fun getTombstone(): Boolean? = entry.tombstone + override fun getTimeserial(): String? = entry.timeserial + override fun getSerialTimestamp(): Long? = entry.serialTimestamp + override fun getData(): ObjectData? = entry.data?.let { DefaultObjectData(it) } +} + +/** + * Decoded public ObjectData: binary is delivered decoded (the wire form is + * base64); there is no `encoding` field in the public shape. Spec: OD2 + */ +internal class DefaultObjectData(private val data: WireObjectData) : ObjectData { + override fun getObjectId(): String? = data.objectId + override fun getString(): String? = data.string + override fun getNumber(): Double? = data.number + override fun getBoolean(): Boolean? = data.boolean + override fun getBytes(): ByteArray? = data.bytes?.let { Base64.getDecoder().decode(it) } + override fun getJson(): JsonElement? = data.json +} + +/** Internal action -> public enum; unrecognized wire values map to UNKNOWN. Spec: PAOOP2a, OOP2 */ +internal fun WireObjectOperationAction.toPublic(): ObjectOperationAction = when (this) { + WireObjectOperationAction.MapCreate -> ObjectOperationAction.MAP_CREATE + WireObjectOperationAction.MapSet -> ObjectOperationAction.MAP_SET + WireObjectOperationAction.MapRemove -> ObjectOperationAction.MAP_REMOVE + WireObjectOperationAction.CounterCreate -> ObjectOperationAction.COUNTER_CREATE + WireObjectOperationAction.CounterInc -> ObjectOperationAction.COUNTER_INC + WireObjectOperationAction.ObjectDelete -> ObjectOperationAction.OBJECT_DELETE + WireObjectOperationAction.MapClear -> ObjectOperationAction.MAP_CLEAR + WireObjectOperationAction.Unknown -> ObjectOperationAction.UNKNOWN +} + +/** Internal semantics -> public enum. Spec: OMP2 */ +internal fun WireObjectsMapSemantics.toPublic(): ObjectsMapSemantics = when (this) { + WireObjectsMapSemantics.LWW -> ObjectsMapSemantics.LWW + WireObjectsMapSemantics.Unknown -> ObjectsMapSemantics.UNKNOWN +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/WireObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/WireObjectMessage.kt new file mode 100644 index 000000000..e29b73533 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/WireObjectMessage.kt @@ -0,0 +1,290 @@ +package io.ably.lib.liveobjects.message + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import io.ably.lib.liveobjects.byteSize +import io.ably.lib.liveobjects.serialization.WireObjectDataJsonSerializer +import io.ably.lib.liveobjects.serialization.gson +import java.util.Base64 + +/** + * Wire-level object model for the path-based public API implementation. + * + * Copied from the legacy internal model (`io.ably.lib.objects.ObjectMessage`) + * so that this package has no dependency on `io.ably.lib.objects`. The `Wire` + * prefix distinguishes these internal carriers from the public interfaces in + * `io.ably.lib.object.message`. + * + * Spec: OM*, OOP*, OD*, MCR*, MST*, MRM*, CCR*, CIN*, ODE*, MCL*, OME*, MCRO*, CCRO*, OMP*, OCN*, OST* + */ + +/** Spec: OOP2 */ +internal enum class WireObjectOperationAction(val code: Int) { + MapCreate(0), + MapSet(1), + MapRemove(2), + CounterCreate(3), + CounterInc(4), + ObjectDelete(5), + MapClear(6), + Unknown(-1); // code for unknown value during deserialization +} + +/** Spec: OMP2 */ +internal enum class WireObjectsMapSemantics(val code: Int) { + LWW(0), + Unknown(-1); // code for unknown value during deserialization +} + +/** Spec: OD1, OD2 - binary carried as base64 string on the wire */ +@JsonAdapter(WireObjectDataJsonSerializer::class) +internal data class WireObjectData( + val objectId: String? = null, // OD2a + val string: String? = null, // OD2f + val number: Double? = null, // OD2e + val boolean: Boolean? = null, // OD2c + val bytes: String? = null, // OD2d - base64 + val json: JsonElement? = null, // decoded JSON leaf +) + +/** Spec: MCR2 */ +internal data class WireMapCreate( + val semantics: WireObjectsMapSemantics, // MCR2a + val entries: Map, // MCR2b +) + +/** Spec: MST2 */ +internal data class WireMapSet( + val key: String, // MST2a + val value: WireObjectData, // MST2b +) + +/** Spec: MRM2 */ +internal data class WireMapRemove( + val key: String, // MRM2a +) + +/** Spec: CCR2 */ +internal data class WireCounterCreate( + val count: Double, // CCR2a +) + +/** Spec: CIN2 */ +internal data class WireCounterInc( + val number: Double, // CIN2a +) + +/** Spec: ODE2 - no attributes */ +internal object WireObjectDelete + +/** Spec: MCL2 - no attributes */ +internal object WireMapClear + +/** Spec: MCRO2 */ +internal data class WireMapCreateWithObjectId( + val initialValue: String, // MCRO2a + val nonce: String, // MCRO2b + @Transient val derivedFrom: WireMapCreate? = null, // RTLMV4j5 - local use only +) + +/** Spec: CCRO2 */ +internal data class WireCounterCreateWithObjectId( + val initialValue: String, // CCRO2a + val nonce: String, // CCRO2b + @Transient val derivedFrom: WireCounterCreate? = null, // RTLCV4g5 - local use only +) + +/** Spec: OME2 */ +internal data class WireObjectsMapEntry( + val tombstone: Boolean? = null, // OME2a + val timeserial: String? = null, // OME2b + val serialTimestamp: Long? = null, // OME2d + val data: WireObjectData? = null, // OME2c +) + +/** Spec: OMP1 */ +internal data class WireObjectsMap( + val semantics: WireObjectsMapSemantics? = null, // OMP3a + val entries: Map? = null, // OMP3b + val clearTimeserial: String? = null, // OMP3c +) + +/** Spec: OCN1 */ +internal data class WireObjectsCounter( + val count: Double? = null, // OCN2a +) + +/** Spec: OOP3 */ +internal data class WireObjectOperation( + val action: WireObjectOperationAction, // OOP3a + val objectId: String, // OOP3b + val mapCreate: WireMapCreate? = null, // OOP3j + val mapSet: WireMapSet? = null, // OOP3k + val mapRemove: WireMapRemove? = null, // OOP3l + val counterCreate: WireCounterCreate? = null, // OOP3m + val counterInc: WireCounterInc? = null, // OOP3n + val objectDelete: WireObjectDelete? = null, // OOP3o + val mapCreateWithObjectId: WireMapCreateWithObjectId? = null, // OOP3p + val counterCreateWithObjectId: WireCounterCreateWithObjectId? = null, // OOP3q + val mapClear: WireMapClear? = null, // OOP3r +) + +/** Spec: OST1 */ +internal data class WireObjectState( + val objectId: String, // OST2a + val siteTimeserials: Map, // OST2b + val tombstone: Boolean, // OST2c + val createOp: WireObjectOperation? = null, // OST2d + val map: WireObjectsMap? = null, // OST2e + val counter: WireObjectsCounter? = null, // OST2f +) + +/** Spec: OM2 */ +internal data class WireObjectMessage( + val id: String? = null, // OM2a + val timestamp: Long? = null, // OM2e + val clientId: String? = null, // OM2b + val connectionId: String? = null, // OM2c + val extras: JsonObject? = null, // OM2d + val operation: WireObjectOperation? = null, // OM2f + @SerializedName("object") + val objectState: WireObjectState? = null, // OM2g - wire key "object" + val serial: String? = null, // OM2h + val serialTimestamp: Long? = null, // OM2j + val siteCode: String? = null, // OM2i +) + +/** + * Calculates the size of an ObjectMessage in bytes. + * Spec: OM3 + */ +internal fun WireObjectMessage.size(): Int { + val clientIdSize = clientId?.byteSize ?: 0 // Spec: OM3f + val operationSize = operation?.size() ?: 0 // Spec: OM3b, OOP4 + val objectStateSize = objectState?.size() ?: 0 // Spec: OM3c, OST3 + val extrasSize = extras?.let { gson.toJson(it).length } ?: 0 // Spec: OM3d + + return clientIdSize + operationSize + objectStateSize + extrasSize +} + +/** + * Calculates the size of an ObjectOperation in bytes. + * Spec: OOP4 + */ +private fun WireObjectOperation.size(): Int { + val mapCreateSize = mapCreate?.size() ?: mapCreateWithObjectId?.derivedFrom?.size() ?: 0 + val mapSetSize = mapSet?.size() ?: 0 + val mapRemoveSize = mapRemove?.size() ?: 0 + val counterCreateSize = counterCreate?.size() ?: counterCreateWithObjectId?.derivedFrom?.size() ?: 0 + val counterIncSize = counterInc?.size() ?: 0 + + return mapCreateSize + mapSetSize + mapRemoveSize + + counterCreateSize + counterIncSize +} + +/** + * Calculates the size of an ObjectState in bytes. + * Spec: OST3 + */ +private fun WireObjectState.size(): Int { + val mapSize = map?.size() ?: 0 // Spec: OST3b, OMP4 + val counterSize = counter?.size() ?: 0 // Spec: OST3c, OCN3 + val createOpSize = createOp?.size() ?: 0 // Spec: OST3d, OOP4 + + return mapSize + counterSize + createOpSize +} + +/** + * Calculates the size of a MapCreate payload in bytes. + */ +private fun WireMapCreate.size(): Int { + return entries.entries.sumOf { it.key.byteSize + it.value.size() } +} + +/** + * Calculates the size of a MapSet payload in bytes. + */ +private fun WireMapSet.size(): Int { + return key.byteSize + value.size() +} + +/** + * Calculates the size of a MapRemove payload in bytes. + */ +private fun WireMapRemove.size(): Int { + return key.byteSize +} + +/** + * Calculates the size of a CounterCreate payload in bytes. + */ +private fun WireCounterCreate.size(): Int { + return 8 // Double is 8 bytes +} + +/** + * Calculates the size of a CounterInc payload in bytes. + */ +private fun WireCounterInc.size(): Int { + return 8 // Double is 8 bytes +} + +/** + * Calculates the size of a MapCreateWithObjectId payload in bytes. + */ +private fun WireMapCreateWithObjectId.size(): Int { + return initialValue.byteSize + nonce.byteSize +} + +/** + * Calculates the size of a CounterCreateWithObjectId payload in bytes. + */ +private fun WireCounterCreateWithObjectId.size(): Int { + return initialValue.byteSize + nonce.byteSize +} + +/** + * Calculates the size of an ObjectMap in bytes. + * Spec: OMP4 + */ +private fun WireObjectsMap.size(): Int { + // Calculate the size of all map entries in the map property + val entriesSize = entries?.entries?.sumOf { + it.key.length + it.value.size() // // Spec: OMP4a1, OMP4a2 + } ?: 0 + + return entriesSize +} + +/** + * Calculates the size of an ObjectCounter in bytes. + * Spec: OCN3 + */ +private fun WireObjectsCounter.size(): Int { + // Size is 8 if count is a number, 0 if count is null or omitted + return if (count != null) 8 else 0 +} + +/** + * Calculates the size of a MapEntry in bytes. + * Spec: OME3 + */ +private fun WireObjectsMapEntry.size(): Int { + // The size is equal to the size of the data property, calculated per "OD3" + return data?.size() ?: 0 +} + +/** + * Calculates the size of an ObjectData in bytes. + * Spec: OD3 + */ +private fun WireObjectData.size(): Int { + string?.let { return it.byteSize } // Spec: OD3e + number?.let { return 8 } // Spec: OD3d + boolean?.let { return 1 } // Spec: OD3b + bytes?.let { return Base64.getDecoder().decode(it).size } // Spec: OD3c + json?.let { return it.toString().byteSize } // Spec: OD3e + return 0 +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObject.kt new file mode 100644 index 000000000..4cc3a38c8 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObject.kt @@ -0,0 +1,102 @@ +package io.ably.lib.liveobjects.path + +import com.google.gson.JsonElement +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.Subscription +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.Instance +import io.ably.lib.liveobjects.onceSubscription +import io.ably.lib.liveobjects.path.types.BinaryPathObject +import io.ably.lib.liveobjects.path.types.BooleanPathObject +import io.ably.lib.liveobjects.path.types.DefaultBinaryPathObject +import io.ably.lib.liveobjects.path.types.DefaultBooleanPathObject +import io.ably.lib.liveobjects.path.types.DefaultJsonArrayPathObject +import io.ably.lib.liveobjects.path.types.DefaultJsonObjectPathObject +import io.ably.lib.liveobjects.path.types.DefaultLiveCounterPathObject +import io.ably.lib.liveobjects.path.types.DefaultLiveMapPathObject +import io.ably.lib.liveobjects.path.types.DefaultNumberPathObject +import io.ably.lib.liveobjects.path.types.DefaultStringPathObject +import io.ably.lib.liveobjects.path.types.JsonArrayPathObject +import io.ably.lib.liveobjects.path.types.JsonObjectPathObject +import io.ably.lib.liveobjects.path.types.LiveCounterPathObject +import io.ably.lib.liveobjects.path.types.LiveMapPathObject +import io.ably.lib.liveobjects.path.types.NumberPathObject +import io.ably.lib.liveobjects.path.types.StringPathObject +import io.ably.lib.liveobjects.value.ResolvedValue +import io.ably.lib.liveobjects.value.valueType + +/** + * Default implementation of [PathObject], the untyped node in the path-addressed view of + * the LiveObjects graph. + * + * This is a skeleton. The `as*` casts return a typed view of the same position; the + * operations that require resolving the path against the live objects graph are left + * unimplemented for now and will be filled in as the path-based API is built out. + * + * Spec: RTPO1, RTPO2, RTTS3 + */ +internal open class DefaultPathObject( + internal val channelObject: DefaultRealtimeObject, + internal val path: String +) : PathObject { + + override fun path(): String = path + + override fun getType(): ValueType? { + channelObject.throwIfInvalidAccessApiConfiguration() + return resolveValueAtPath(path)?.valueType() + } + + override fun instance(): Instance? { + channelObject.throwIfInvalidAccessApiConfiguration() + val resolvedValue = resolveValueAtPath(path) ?: return null // unresolved path -> no instance + return when (resolvedValue) { + is ResolvedValue.Leaf -> null // primitives have no Instance; only live objects do + // TODO - wrap the resolved live object (LiveMap/LiveCounter) in an Instance + is ResolvedValue.MapRef, is ResolvedValue.CounterRef -> TODO("Not yet implemented") + } + } + + override fun compactJson(): JsonElement? { + channelObject.throwIfInvalidAccessApiConfiguration() + resolveValueAtPath(path) ?: return null // unresolved path -> null + // TODO - build the compacted JSON snapshot (LiveMap -> JsonObject, LiveCounter -> number, leaf -> JSON value) + TODO("Not yet implemented") + } + + override fun exists(): Boolean { + channelObject.throwIfInvalidAccessApiConfiguration() + return resolveValueAtPath(path) != null + } + + override fun asLiveMap(): LiveMapPathObject = DefaultLiveMapPathObject(channelObject, path) + + override fun asLiveCounter(): LiveCounterPathObject = DefaultLiveCounterPathObject(channelObject, path) + + override fun asNumber(): NumberPathObject = DefaultNumberPathObject(channelObject, path) + + override fun asString(): StringPathObject = DefaultStringPathObject(channelObject, path) + + override fun asBoolean(): BooleanPathObject = DefaultBooleanPathObject(channelObject, path) + + override fun asBinary(): BinaryPathObject = DefaultBinaryPathObject(channelObject, path) + + override fun asJsonObject(): JsonObjectPathObject = DefaultJsonObjectPathObject(channelObject, path) + + override fun asJsonArray(): JsonArrayPathObject = DefaultJsonArrayPathObject(channelObject, path) + + override fun subscribe(listener: PathObjectListener): Subscription = subscribe(listener, null) + + override fun subscribe(listener: PathObjectListener, options: PathObjectSubscriptionOptions?): Subscription { + channelObject.throwIfInvalidAccessApiConfiguration() + // TODO - subscribe logic goes here + return onceSubscription { + // TODO - remove PathObjectListener from list + } + } + + protected fun resolveValueAtPath(path: String): ResolvedValue? { + // TODO - resolve the path against the live objects graph and return the value at that position + TODO("Not yet implemented") + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObjectSubscriptionEvent.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObjectSubscriptionEvent.kt new file mode 100644 index 000000000..17e474807 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObjectSubscriptionEvent.kt @@ -0,0 +1,20 @@ +package io.ably.lib.liveobjects.path + +import io.ably.lib.liveobjects.message.ObjectMessage + +/** + * Default implementation of [PathObjectSubscriptionEvent], the event delivered to a + * [PathObjectListener] when a change affects the subscribed path. A plain holder for the + * changed [PathObject] and the source [ObjectMessage] (if any). + * + * Spec: RTPO19e / RTTS3d + */ +internal class DefaultPathObjectSubscriptionEvent( + private val pathObject: PathObject, + private val message: ObjectMessage?, +) : PathObjectSubscriptionEvent { + + override fun getObject(): PathObject = pathObject + + override fun getMessage(): ObjectMessage? = message +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBinaryPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBinaryPathObject.kt new file mode 100644 index 000000000..35ac94c5d --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBinaryPathObject.kt @@ -0,0 +1,25 @@ +package io.ably.lib.liveobjects.path.types + +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.value.valueType + +/** + * Default implementation of [BinaryPathObject], a terminal primitive view that only adds a + * type-narrowed [value]; left unimplemented for now. + * + * Spec: RTTS6c + */ +internal class DefaultBinaryPathObject( + channelObject: DefaultRealtimeObject, + path: String, +) : DefaultPathObject(channelObject, path), BinaryPathObject { + + override fun value(): ByteArray? { + channelObject.throwIfInvalidAccessApiConfiguration() + if (resolveValueAtPath(path)?.valueType() != ValueType.BINARY) return null // not a Binary value at this path -> no value + // TODO - extract the primitive value from the resolved leaf, narrowed to ByteArray (base64-decoded) + TODO("Not yet implemented") + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBooleanPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBooleanPathObject.kt new file mode 100644 index 000000000..e8554f780 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBooleanPathObject.kt @@ -0,0 +1,25 @@ +package io.ably.lib.liveobjects.path.types + +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.value.valueType + +/** + * Default implementation of [BooleanPathObject], a terminal primitive view that only adds a + * type-narrowed [value]; left unimplemented for now. + * + * Spec: RTTS6c + */ +internal class DefaultBooleanPathObject( + channelObject: DefaultRealtimeObject, + path: String, +) : DefaultPathObject(channelObject, path), BooleanPathObject { + + override fun value(): Boolean? { + channelObject.throwIfInvalidAccessApiConfiguration() + if (resolveValueAtPath(path)?.valueType() != ValueType.BOOLEAN) return null // not a Boolean at this path -> no value + // TODO - extract the primitive value from the resolved leaf, narrowed to Boolean + TODO("Not yet implemented") + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonArrayPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonArrayPathObject.kt new file mode 100644 index 000000000..fa40c460f --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonArrayPathObject.kt @@ -0,0 +1,26 @@ +package io.ably.lib.liveobjects.path.types + +import com.google.gson.JsonArray +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.value.valueType + +/** + * Default implementation of [JsonArrayPathObject], a terminal primitive view that only adds + * a type-narrowed [value]; left unimplemented for now. + * + * Spec: RTTS6c + */ +internal class DefaultJsonArrayPathObject( + channelObject: DefaultRealtimeObject, + path: String, +) : DefaultPathObject(channelObject, path), JsonArrayPathObject { + + override fun value(): JsonArray? { + channelObject.throwIfInvalidAccessApiConfiguration() + if (resolveValueAtPath(path)?.valueType() != ValueType.JSON_ARRAY) return null // not a JSON array at this path -> no value + // TODO - extract the primitive value from the resolved leaf, narrowed to JsonArray + TODO("Not yet implemented") + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonObjectPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonObjectPathObject.kt new file mode 100644 index 000000000..e9362fcfe --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonObjectPathObject.kt @@ -0,0 +1,26 @@ +package io.ably.lib.liveobjects.path.types + +import com.google.gson.JsonObject +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.value.valueType + +/** + * Default implementation of [JsonObjectPathObject], a terminal primitive view that only adds + * a type-narrowed [value]; left unimplemented for now. + * + * Spec: RTTS6c + */ +internal class DefaultJsonObjectPathObject( + channelObject: DefaultRealtimeObject, + path: String, +) : DefaultPathObject(channelObject, path), JsonObjectPathObject { + + override fun value(): JsonObject? { + channelObject.throwIfInvalidAccessApiConfiguration() + if (resolveValueAtPath(path)?.valueType() != ValueType.JSON_OBJECT) return null // not a JSON object at this path -> no value + // TODO - extract the primitive value from the resolved leaf, narrowed to JsonObject + TODO("Not yet implemented") + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveCounterPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveCounterPathObject.kt new file mode 100644 index 000000000..6e4e320ca --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveCounterPathObject.kt @@ -0,0 +1,69 @@ +package io.ably.lib.liveobjects.path.types + +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.pathNotResolvedError +import io.ably.lib.liveobjects.typeMismatchError +import io.ably.lib.liveobjects.value.ResolvedValue +import java.util.concurrent.CompletableFuture + +/** + * Default implementation of [LiveCounterPathObject]. + * + * Counters are terminal nodes (no navigation), so this only adds the counter read/write + * operations on top of [DefaultPathObject]; they are left unimplemented for now. + * + * Spec: RTTS6b + */ +internal class DefaultLiveCounterPathObject( + channelObject: DefaultRealtimeObject, + path: String, +) : DefaultPathObject(channelObject, path), LiveCounterPathObject { + + override fun value(): Double? { + channelObject.throwIfInvalidAccessApiConfiguration() + if (resolveValueAtPath(path) !is ResolvedValue.CounterRef) return null // not a LiveCounter (or unresolved) -> null + // TODO - return the resolved counter's value + TODO("Not yet implemented") + } + + override fun increment(): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) + if (resolvedValue !is ResolvedValue.CounterRef) { + throw typeMismatchError("Cannot increment a non-LiveCounter object at path: \"$path\"") + } + // TODO - delegate the COUNTER_INC (amount 1) to the resolved LiveCounter + TODO("Not yet implemented") + } + + override fun increment(amount: Number): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) + if (resolvedValue !is ResolvedValue.CounterRef) { + throw typeMismatchError("Cannot increment a non-LiveCounter object at path: \"$path\"") + } + // TODO - delegate the COUNTER_INC to the resolved LiveCounter + TODO("Not yet implemented") + } + + override fun decrement(): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) + if (resolvedValue !is ResolvedValue.CounterRef) { + throw typeMismatchError("Cannot decrement a non-LiveCounter object at path: \"$path\"") + } + // TODO - delegate the COUNTER_INC (negated amount 1) to the resolved LiveCounter + TODO("Not yet implemented") + } + + override fun decrement(amount: Number): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) + if (resolvedValue !is ResolvedValue.CounterRef) { + throw typeMismatchError("Cannot decrement a non-LiveCounter object at path: \"$path\"") + } + // TODO - delegate the COUNTER_INC (negated amount) to the resolved LiveCounter + TODO("Not yet implemented") + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveMapPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveMapPathObject.kt new file mode 100644 index 000000000..8c6a561d5 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveMapPathObject.kt @@ -0,0 +1,74 @@ +package io.ably.lib.liveobjects.path.types + +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.path.PathObject +import io.ably.lib.liveobjects.pathNotResolvedError +import io.ably.lib.liveobjects.typeMismatchError +import io.ably.lib.liveobjects.value.LiveMapValue +import io.ably.lib.liveobjects.value.ResolvedValue +import java.util.concurrent.CompletableFuture + +/** + * Default implementation of [LiveMapPathObject], adding map navigation and read/write + * operations on top of [DefaultPathObject]; all left unimplemented for now. + * + * Spec: RTTS6a + */ +internal class DefaultLiveMapPathObject( + channelObject: DefaultRealtimeObject, + path: String, +) : DefaultPathObject(channelObject, path), LiveMapPathObject { + + override fun get(key: String): PathObject = TODO("Not yet implemented") + + override fun at(path: String): PathObject = TODO("Not yet implemented") + + override fun entries(): Iterable> { + channelObject.throwIfInvalidAccessApiConfiguration() + if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return emptyList() // not a LiveMap (or unresolved) -> empty + // TODO - iterate the resolved map's entries, yielding (key, child PathObject) + TODO("Not yet implemented") + } + + override fun keys(): Iterable { + channelObject.throwIfInvalidAccessApiConfiguration() + if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return emptyList() // not a LiveMap (or unresolved) -> empty + // TODO - return the resolved map's keys + TODO("Not yet implemented") + } + + override fun values(): Iterable { + channelObject.throwIfInvalidAccessApiConfiguration() + if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return emptyList() // not a LiveMap (or unresolved) -> empty + // TODO - return a child PathObject for each entry of the resolved map + TODO("Not yet implemented") + } + + override fun size(): Long? { + channelObject.throwIfInvalidAccessApiConfiguration() + if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return null // not a LiveMap (or unresolved) -> null + // TODO - return the resolved map's size + TODO("Not yet implemented") + } + + override fun set(key: String, value: LiveMapValue): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) + if (resolvedValue !is ResolvedValue.MapRef) { + throw typeMismatchError("Cannot set a key on a non-LiveMap object at path: \"$path\"") + } + // TODO - delegate the MAP_SET to the resolved LiveMap + TODO("Not yet implemented") + } + + override fun remove(key: String): CompletableFuture { + channelObject.throwIfInvalidWriteApiConfiguration() + val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path) + if (resolvedValue !is ResolvedValue.MapRef) { + throw typeMismatchError("Cannot remove a key from a non-LiveMap object at path: \"$path\"") + } + // TODO - delegate the MAP_REMOVE to the resolved LiveMap + TODO("Not yet implemented") + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultNumberPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultNumberPathObject.kt new file mode 100644 index 000000000..bc64dd28c --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultNumberPathObject.kt @@ -0,0 +1,25 @@ +package io.ably.lib.liveobjects.path.types + +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.value.valueType + +/** + * Default implementation of [NumberPathObject], a terminal primitive view that only adds a + * type-narrowed [value]; left unimplemented for now. + * + * Spec: RTTS6c + */ +internal class DefaultNumberPathObject( + channelObject: DefaultRealtimeObject, + path: String, +) : DefaultPathObject(channelObject, path), NumberPathObject { + + override fun value(): Number? { + channelObject.throwIfInvalidAccessApiConfiguration() + if (resolveValueAtPath(path)?.valueType() != ValueType.NUMBER) return null // not a Number at this path -> no value + // TODO - extract the primitive value from the resolved leaf, narrowed to Number + TODO("Not yet implemented") + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultStringPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultStringPathObject.kt new file mode 100644 index 000000000..4275c84c4 --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultStringPathObject.kt @@ -0,0 +1,25 @@ +package io.ably.lib.liveobjects.path.types + +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.path.DefaultPathObject +import io.ably.lib.liveobjects.value.valueType + +/** + * Default implementation of [StringPathObject], a terminal primitive view that only adds a + * type-narrowed [value]; left unimplemented for now. + * + * Spec: RTTS6c + */ +internal class DefaultStringPathObject( + channelObject: DefaultRealtimeObject, + path: String, +) : DefaultPathObject(channelObject, path), StringPathObject { + + override fun value(): String? { + channelObject.throwIfInvalidAccessApiConfiguration() + if (resolveValueAtPath(path)?.valueType() != ValueType.STRING) return null // not a String at this path -> no value + // TODO - extract the primitive value from the resolved leaf, narrowed to String + TODO("Not yet implemented") + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/DefaultSerialization.kt similarity index 61% rename from liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/DefaultSerialization.kt index 8267a360d..7deb4fe24 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/DefaultSerialization.kt @@ -1,19 +1,17 @@ -package io.ably.lib.objects.serialization +package io.ably.lib.liveobjects.serialization import com.google.gson.* -import io.ably.lib.objects.* - -import io.ably.lib.objects.ObjectMessage +import io.ably.lib.liveobjects.message.WireObjectMessage import org.msgpack.core.MessagePacker import org.msgpack.core.MessageUnpacker /** - * Default implementation of {@link ObjectsSerializer} that handles serialization/deserialization - * of ObjectMessage arrays for both JSON and MessagePack formats using Jackson and Gson. - * Dynamically loaded by ObjectsHelper#getSerializer() to avoid hard dependencies. + * Default implementation of {@link ObjectSerializer} that handles serialization/deserialization + * of WireObjectMessage arrays for both JSON and MessagePack formats using Gson and MessagePack. + * Dynamically loaded by ObjectSerializer#tryGet() to avoid hard dependencies. */ -@Suppress("unused") // Used via reflection in ObjectsHelper -internal class DefaultObjectsSerializer : ObjectsSerializer { +@Suppress("unused") // Used via reflection in ObjectSerializer.Holder +internal class DefaultObjectsSerializer : ObjectSerializer { override fun readMsgpackArray(unpacker: MessageUnpacker): Array { val objectMessagesCount = unpacker.unpackArrayHeader() @@ -21,7 +19,7 @@ internal class DefaultObjectsSerializer : ObjectsSerializer { } override fun writeMsgpackArray(objects: Array, packer: MessagePacker) { - val objectMessages = objects.map { it as ObjectMessage } + val objectMessages = objects.map { it as WireObjectMessage } packer.packArrayHeader(objectMessages.size) objectMessages.forEach { it.writeMsgpack(packer) } } @@ -34,7 +32,7 @@ internal class DefaultObjectsSerializer : ObjectsSerializer { } override fun asJsonArray(objects: Array): JsonArray { - val objectMessages = objects.map { it as ObjectMessage } + val objectMessages = objects.map { it as WireObjectMessage } val jsonArray = JsonArray() for (objectMessage in objectMessages) { jsonArray.add(objectMessage.toJsonObject()) diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/JsonSerialization.kt similarity index 65% rename from liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/JsonSerialization.kt index fbf5acb88..3620dfee9 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/JsonSerialization.kt @@ -1,25 +1,25 @@ -package io.ably.lib.objects.serialization +package io.ably.lib.liveobjects.serialization import com.google.gson.* -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.message.WireObjectsMapSemantics import java.lang.reflect.Type import kotlin.enums.EnumEntries // Gson instance for JSON serialization/deserialization internal val gson = GsonBuilder() - .registerTypeAdapter(ObjectOperationAction::class.java, EnumCodeTypeAdapter({ it.code }, ObjectOperationAction.entries)) - .registerTypeAdapter(ObjectsMapSemantics::class.java, EnumCodeTypeAdapter({ it.code }, ObjectsMapSemantics.entries)) + .registerTypeAdapter(WireObjectOperationAction::class.java, EnumCodeTypeAdapter({ it.code }, WireObjectOperationAction.entries)) + .registerTypeAdapter(WireObjectsMapSemantics::class.java, EnumCodeTypeAdapter({ it.code }, WireObjectsMapSemantics.entries)) .create() -internal fun ObjectMessage.toJsonObject(): JsonObject { +internal fun WireObjectMessage.toJsonObject(): JsonObject { return gson.toJsonTree(this).asJsonObject } -internal fun JsonObject.toObjectMessage(): ObjectMessage { - return gson.fromJson(this, ObjectMessage::class.java) +internal fun JsonObject.toObjectMessage(): WireObjectMessage { + return gson.fromJson(this, WireObjectMessage::class.java) } internal class EnumCodeTypeAdapter>( @@ -38,8 +38,8 @@ internal class EnumCodeTypeAdapter>( } } -internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeserializer { - override fun serialize(src: ObjectData, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { +internal class WireObjectDataJsonSerializer : JsonSerializer, JsonDeserializer { + override fun serialize(src: WireObjectData, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { val obj = JsonObject() src.objectId?.let { obj.addProperty("objectId", it) } src.string?.let { obj.addProperty("string", it) } @@ -50,7 +50,7 @@ internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeseri return obj } - override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): ObjectData { + override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): WireObjectData { val obj = if (json.isJsonObject) json.asJsonObject else throw JsonParseException("Expected JsonObject") val objectId = if (obj.has("objectId")) obj.get("objectId").asString else null val string = if (obj.has("string")) obj.get("string").asString else null @@ -62,6 +62,6 @@ internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeseri if (objectId == null && string == null && number == null && boolean == null && bytes == null && json == null) { throw JsonParseException("Since objectId is not present, at least one of the value fields must be present") } - return ObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json) + return WireObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json) } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/MsgpackSerialization.kt similarity index 70% rename from liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/MsgpackSerialization.kt index 2eb10d0bd..18d5c0701 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/MsgpackSerialization.kt @@ -1,38 +1,37 @@ -package io.ably.lib.objects.serialization +package io.ably.lib.liveobjects.serialization import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterCreateWithObjectId -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.ErrorCode -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapCreateWithObjectId -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.MapClear -import io.ably.lib.objects.ObjectDelete -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectsCounter -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectsMap -import io.ably.lib.objects.ObjectsMapEntry -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectState -import java.util.Base64 +import io.ably.lib.liveobjects.message.WireCounterCreate +import io.ably.lib.liveobjects.message.WireCounterCreateWithObjectId +import io.ably.lib.liveobjects.message.WireCounterInc +import io.ably.lib.liveobjects.message.WireMapClear +import io.ably.lib.liveobjects.message.WireMapCreate +import io.ably.lib.liveobjects.message.WireMapCreateWithObjectId +import io.ably.lib.liveobjects.message.WireMapRemove +import io.ably.lib.liveobjects.message.WireMapSet +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectDelete +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperation +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.message.WireObjectState +import io.ably.lib.liveobjects.message.WireObjectsCounter +import io.ably.lib.liveobjects.message.WireObjectsMap +import io.ably.lib.liveobjects.message.WireObjectsMapEntry +import io.ably.lib.liveobjects.message.WireObjectsMapSemantics +import io.ably.lib.liveobjects.objectStateError import io.ably.lib.util.Serialisation +import java.util.Base64 import org.msgpack.core.MessageFormat import org.msgpack.core.MessagePacker import org.msgpack.core.MessageUnpacker /** - * Write ObjectMessage to MessagePacker + * Write WireObjectMessage to MessagePacker */ -internal fun ObjectMessage.writeMsgpack(packer: MessagePacker) { +internal fun WireObjectMessage.writeMsgpack(packer: MessagePacker) { var fieldCount = 0 if (id != null) fieldCount++ @@ -100,12 +99,12 @@ internal fun ObjectMessage.writeMsgpack(packer: MessagePacker) { } /** - * Read an ObjectMessage from MessageUnpacker + * Read a WireObjectMessage from MessageUnpacker */ -internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage { +internal fun readObjectMessage(unpacker: MessageUnpacker): WireObjectMessage { if (unpacker.nextFormat == MessageFormat.NIL) { unpacker.unpackNil() - return ObjectMessage() // default/empty message + return WireObjectMessage() // default/empty message } val fieldCount = unpacker.unpackMapHeader() @@ -115,8 +114,8 @@ internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage { var clientId: String? = null var connectionId: String? = null var extras: JsonObject? = null - var operation: ObjectOperation? = null - var objectState: ObjectState? = null + var operation: WireObjectOperation? = null + var objectState: WireObjectState? = null var serial: String? = null var serialTimestamp: Long? = null var siteCode: String? = null @@ -145,7 +144,7 @@ internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage { } } - return ObjectMessage( + return WireObjectMessage( id = id, timestamp = timestamp, clientId = clientId, @@ -160,9 +159,9 @@ internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage { } /** - * Write ObjectOperation to MessagePacker + * Write WireObjectOperation to MessagePacker */ -private fun ObjectOperation.writeMsgpack(packer: MessagePacker) { +private fun WireObjectOperation.writeMsgpack(packer: MessagePacker) { var fieldCount = 1 // action is always required require(objectId.isNotEmpty()) { "objectId must be non-empty per Objects protocol" } fieldCount++ @@ -234,22 +233,22 @@ private fun ObjectOperation.writeMsgpack(packer: MessagePacker) { } /** - * Read ObjectOperation from MessageUnpacker + * Read WireObjectOperation from MessageUnpacker */ -private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation { +private fun readObjectOperation(unpacker: MessageUnpacker): WireObjectOperation { val fieldCount = unpacker.unpackMapHeader() - var action: ObjectOperationAction? = null + var action: WireObjectOperationAction? = null var objectId: String = "" - var mapCreate: MapCreate? = null - var mapSet: MapSet? = null - var mapRemove: MapRemove? = null - var counterCreate: CounterCreate? = null - var counterInc: CounterInc? = null - var objectDelete: ObjectDelete? = null - var mapCreateWithObjectId: MapCreateWithObjectId? = null - var counterCreateWithObjectId: CounterCreateWithObjectId? = null - var mapClear: MapClear? = null + var mapCreate: WireMapCreate? = null + var mapSet: WireMapSet? = null + var mapRemove: WireMapRemove? = null + var counterCreate: WireCounterCreate? = null + var counterInc: WireCounterInc? = null + var objectDelete: WireObjectDelete? = null + var mapCreateWithObjectId: WireMapCreateWithObjectId? = null + var counterCreateWithObjectId: WireCounterCreateWithObjectId? = null + var mapClear: WireMapClear? = null for (i in 0 until fieldCount) { val fieldName = unpacker.unpackString().intern() @@ -263,9 +262,9 @@ private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation { when (fieldName) { "action" -> { val actionCode = unpacker.unpackInt() - action = ObjectOperationAction.entries.firstOrNull { it.code == actionCode } - ?: ObjectOperationAction.entries.firstOrNull { it.code == -1 } - ?: throw objectError("Unknown ObjectOperationAction code: $actionCode and no Unknown fallback found") + action = WireObjectOperationAction.entries.firstOrNull { it.code == actionCode } + ?: WireObjectOperationAction.entries.firstOrNull { it.code == -1 } + ?: throw objectStateError("Unknown WireObjectOperationAction code: $actionCode and no Unknown fallback found") } "objectId" -> objectId = unpacker.unpackString() "mapCreate" -> mapCreate = readMapCreate(unpacker) @@ -275,23 +274,27 @@ private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation { "counterInc" -> counterInc = readCounterInc(unpacker) "objectDelete" -> { unpacker.skipValue() // empty map, just consume it - objectDelete = ObjectDelete + objectDelete = WireObjectDelete } "mapCreateWithObjectId" -> mapCreateWithObjectId = readMapCreateWithObjectId(unpacker) "counterCreateWithObjectId" -> counterCreateWithObjectId = readCounterCreateWithObjectId(unpacker) "mapClear" -> { unpacker.skipValue() // empty map, consume it - mapClear = MapClear + mapClear = WireMapClear } else -> unpacker.skipValue() } } if (action == null) { - throw objectError("Missing required 'action' field in ObjectOperation") + throw objectStateError("Missing required 'action' field in WireObjectOperation") + } + + if (objectId.isEmpty()) { + throw objectStateError("Missing required 'objectId' field in WireObjectOperation") } - return ObjectOperation( + return WireObjectOperation( action = action, objectId = objectId, mapCreate = mapCreate, @@ -307,9 +310,9 @@ private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation { } /** - * Write ObjectState to MessagePacker + * Write WireObjectState to MessagePacker */ -private fun ObjectState.writeMsgpack(packer: MessagePacker) { +private fun WireObjectState.writeMsgpack(packer: MessagePacker) { var fieldCount = 3 // objectId, siteTimeserials, and tombstone are required if (createOp != null) fieldCount++ @@ -348,17 +351,17 @@ private fun ObjectState.writeMsgpack(packer: MessagePacker) { } /** - * Read ObjectState from MessageUnpacker + * Read WireObjectState from MessageUnpacker */ -private fun readObjectState(unpacker: MessageUnpacker): ObjectState { +private fun readObjectState(unpacker: MessageUnpacker): WireObjectState { val fieldCount = unpacker.unpackMapHeader() var objectId = "" var siteTimeserials = mapOf() var tombstone = false - var createOp: ObjectOperation? = null - var map: ObjectsMap? = null - var counter: ObjectsCounter? = null + var createOp: WireObjectOperation? = null + var map: WireObjectsMap? = null + var counter: WireObjectsCounter? = null for (i in 0 until fieldCount) { val fieldName = unpacker.unpackString().intern() @@ -389,7 +392,11 @@ private fun readObjectState(unpacker: MessageUnpacker): ObjectState { } } - return ObjectState( + if (objectId.isEmpty()) { + throw objectStateError("Missing required 'objectId' field in WireObjectState") + } + + return WireObjectState( objectId = objectId, siteTimeserials = siteTimeserials, tombstone = tombstone, @@ -400,9 +407,9 @@ private fun readObjectState(unpacker: MessageUnpacker): ObjectState { } /** - * Write MapCreate to MessagePacker + * Write WireMapCreate to MessagePacker */ -private fun MapCreate.writeMsgpack(packer: MessagePacker) { +private fun WireMapCreate.writeMsgpack(packer: MessagePacker) { packer.packMapHeader(2) packer.packString("semantics") packer.packInt(semantics.code) @@ -415,12 +422,12 @@ private fun MapCreate.writeMsgpack(packer: MessagePacker) { } /** - * Read MapCreate from MessageUnpacker + * Read WireMapCreate from MessageUnpacker */ -private fun readMapCreate(unpacker: MessageUnpacker): MapCreate { +private fun readMapCreate(unpacker: MessageUnpacker): WireMapCreate { val fieldCount = unpacker.unpackMapHeader() - var semantics: ObjectsMapSemantics = ObjectsMapSemantics.LWW - var entries: Map = emptyMap() + var semantics: WireObjectsMapSemantics = WireObjectsMapSemantics.LWW + var entries: Map = emptyMap() for (i in 0 until fieldCount) { val fieldName = unpacker.unpackString().intern() @@ -429,13 +436,13 @@ private fun readMapCreate(unpacker: MessageUnpacker): MapCreate { when (fieldName) { "semantics" -> { val code = unpacker.unpackInt() - semantics = ObjectsMapSemantics.entries.firstOrNull { it.code == code } - ?: ObjectsMapSemantics.entries.firstOrNull { it.code == -1 } - ?: throw objectError("Unknown MapSemantics code: $code and no UNKNOWN fallback found") + semantics = WireObjectsMapSemantics.entries.firstOrNull { it.code == code } + ?: WireObjectsMapSemantics.entries.firstOrNull { it.code == -1 } + ?: throw objectStateError("Unknown MapSemantics code: $code and no UNKNOWN fallback found") } "entries" -> { val mapSize = unpacker.unpackMapHeader() - val tempMap = mutableMapOf() + val tempMap = mutableMapOf() for (j in 0 until mapSize) { tempMap[unpacker.unpackString()] = readObjectMapEntry(unpacker) } @@ -444,13 +451,13 @@ private fun readMapCreate(unpacker: MessageUnpacker): MapCreate { else -> unpacker.skipValue() } } - return MapCreate(semantics = semantics, entries = entries) + return WireMapCreate(semantics = semantics, entries = entries) } /** - * Write MapSet to MessagePacker + * Write WireMapSet to MessagePacker */ -private fun MapSet.writeMsgpack(packer: MessagePacker) { +private fun WireMapSet.writeMsgpack(packer: MessagePacker) { packer.packMapHeader(2) packer.packString("key") packer.packString(key) @@ -459,12 +466,12 @@ private fun MapSet.writeMsgpack(packer: MessagePacker) { } /** - * Read MapSet from MessageUnpacker + * Read WireMapSet from MessageUnpacker */ -private fun readMapSet(unpacker: MessageUnpacker): MapSet { +private fun readMapSet(unpacker: MessageUnpacker): WireMapSet { val fieldCount = unpacker.unpackMapHeader() var key: String? = null - var value: ObjectData? = null + var value: WireObjectData? = null for (i in 0 until fieldCount) { val fieldName = unpacker.unpackString().intern() @@ -476,25 +483,25 @@ private fun readMapSet(unpacker: MessageUnpacker): MapSet { else -> unpacker.skipValue() } } - return MapSet( - key = key ?: throw objectError("Missing 'key' in MapSet payload"), - value = value ?: throw objectError("Missing 'value' in MapSet payload") + return WireMapSet( + key = key ?: throw objectStateError("Missing 'key' in WireMapSet payload"), + value = value ?: throw objectStateError("Missing 'value' in WireMapSet payload") ) } /** - * Write MapRemove to MessagePacker + * Write WireMapRemove to MessagePacker */ -private fun MapRemove.writeMsgpack(packer: MessagePacker) { +private fun WireMapRemove.writeMsgpack(packer: MessagePacker) { packer.packMapHeader(1) packer.packString("key") packer.packString(key) } /** - * Read MapRemove from MessageUnpacker + * Read WireMapRemove from MessageUnpacker */ -private fun readMapRemove(unpacker: MessageUnpacker): MapRemove { +private fun readMapRemove(unpacker: MessageUnpacker): WireMapRemove { val fieldCount = unpacker.unpackMapHeader() var key: String? = null @@ -507,22 +514,22 @@ private fun readMapRemove(unpacker: MessageUnpacker): MapRemove { else -> unpacker.skipValue() } } - return MapRemove(key = key ?: throw objectError("Missing 'key' in MapRemove payload")) + return WireMapRemove(key = key ?: throw objectStateError("Missing 'key' in WireMapRemove payload")) } /** - * Write CounterCreate to MessagePacker + * Write WireCounterCreate to MessagePacker */ -private fun CounterCreate.writeMsgpack(packer: MessagePacker) { +private fun WireCounterCreate.writeMsgpack(packer: MessagePacker) { packer.packMapHeader(1) packer.packString("count") packer.packDouble(count) } /** - * Read CounterCreate from MessageUnpacker + * Read WireCounterCreate from MessageUnpacker */ -private fun readCounterCreate(unpacker: MessageUnpacker): CounterCreate { +private fun readCounterCreate(unpacker: MessageUnpacker): WireCounterCreate { val fieldCount = unpacker.unpackMapHeader() var count: Double? = null @@ -535,22 +542,22 @@ private fun readCounterCreate(unpacker: MessageUnpacker): CounterCreate { else -> unpacker.skipValue() } } - return CounterCreate(count = count ?: throw objectError("Missing 'count' in CounterCreate payload")) + return WireCounterCreate(count = count ?: throw objectStateError("Missing 'count' in WireCounterCreate payload")) } /** - * Write CounterInc to MessagePacker + * Write WireCounterInc to MessagePacker */ -private fun CounterInc.writeMsgpack(packer: MessagePacker) { +private fun WireCounterInc.writeMsgpack(packer: MessagePacker) { packer.packMapHeader(1) packer.packString("number") packer.packDouble(number) } /** - * Read CounterInc from MessageUnpacker + * Read WireCounterInc from MessageUnpacker */ -private fun readCounterInc(unpacker: MessageUnpacker): CounterInc { +private fun readCounterInc(unpacker: MessageUnpacker): WireCounterInc { val fieldCount = unpacker.unpackMapHeader() var number: Double? = null @@ -563,13 +570,13 @@ private fun readCounterInc(unpacker: MessageUnpacker): CounterInc { else -> unpacker.skipValue() } } - return CounterInc(number = number ?: throw objectError("Missing 'number' in CounterInc payload")) + return WireCounterInc(number = number ?: throw objectStateError("Missing 'number' in WireCounterInc payload")) } /** - * Write MapCreateWithObjectId to MessagePacker + * Write WireMapCreateWithObjectId to MessagePacker */ -private fun MapCreateWithObjectId.writeMsgpack(packer: MessagePacker) { +private fun WireMapCreateWithObjectId.writeMsgpack(packer: MessagePacker) { packer.packMapHeader(2) packer.packString("initialValue") packer.packString(initialValue) @@ -578,9 +585,9 @@ private fun MapCreateWithObjectId.writeMsgpack(packer: MessagePacker) { } /** - * Read MapCreateWithObjectId from MessageUnpacker + * Read WireMapCreateWithObjectId from MessageUnpacker */ -private fun readMapCreateWithObjectId(unpacker: MessageUnpacker): MapCreateWithObjectId { +private fun readMapCreateWithObjectId(unpacker: MessageUnpacker): WireMapCreateWithObjectId { val fieldCount = unpacker.unpackMapHeader() var initialValue: String? = null var nonce: String? = null @@ -595,16 +602,16 @@ private fun readMapCreateWithObjectId(unpacker: MessageUnpacker): MapCreateWithO else -> unpacker.skipValue() } } - return MapCreateWithObjectId( - initialValue = initialValue ?: throw objectError("Missing 'initialValue' in MapCreateWithObjectId payload"), - nonce = nonce ?: throw objectError("Missing 'nonce' in MapCreateWithObjectId payload") + return WireMapCreateWithObjectId( + initialValue = initialValue ?: throw objectStateError("Missing 'initialValue' in WireMapCreateWithObjectId payload"), + nonce = nonce ?: throw objectStateError("Missing 'nonce' in WireMapCreateWithObjectId payload") ) } /** - * Write CounterCreateWithObjectId to MessagePacker + * Write WireCounterCreateWithObjectId to MessagePacker */ -private fun CounterCreateWithObjectId.writeMsgpack(packer: MessagePacker) { +private fun WireCounterCreateWithObjectId.writeMsgpack(packer: MessagePacker) { packer.packMapHeader(2) packer.packString("initialValue") packer.packString(initialValue) @@ -613,9 +620,9 @@ private fun CounterCreateWithObjectId.writeMsgpack(packer: MessagePacker) { } /** - * Read CounterCreateWithObjectId from MessageUnpacker + * Read WireCounterCreateWithObjectId from MessageUnpacker */ -private fun readCounterCreateWithObjectId(unpacker: MessageUnpacker): CounterCreateWithObjectId { +private fun readCounterCreateWithObjectId(unpacker: MessageUnpacker): WireCounterCreateWithObjectId { val fieldCount = unpacker.unpackMapHeader() var initialValue: String? = null var nonce: String? = null @@ -630,16 +637,16 @@ private fun readCounterCreateWithObjectId(unpacker: MessageUnpacker): CounterCre else -> unpacker.skipValue() } } - return CounterCreateWithObjectId( - initialValue = initialValue ?: throw objectError("Missing 'initialValue' in CounterCreateWithObjectId payload"), - nonce = nonce ?: throw objectError("Missing 'nonce' in CounterCreateWithObjectId payload") + return WireCounterCreateWithObjectId( + initialValue = initialValue ?: throw objectStateError("Missing 'initialValue' in WireCounterCreateWithObjectId payload"), + nonce = nonce ?: throw objectStateError("Missing 'nonce' in WireCounterCreateWithObjectId payload") ) } /** * Write ObjectMap to MessagePacker */ -private fun ObjectsMap.writeMsgpack(packer: MessagePacker) { +private fun WireObjectsMap.writeMsgpack(packer: MessagePacker) { var fieldCount = 0 if (semantics != null) fieldCount++ @@ -671,11 +678,11 @@ private fun ObjectsMap.writeMsgpack(packer: MessagePacker) { /** * Read ObjectMap from MessageUnpacker */ -private fun readObjectMap(unpacker: MessageUnpacker): ObjectsMap { +private fun readObjectMap(unpacker: MessageUnpacker): WireObjectsMap { val fieldCount = unpacker.unpackMapHeader() - var semantics: ObjectsMapSemantics? = null - var entries: Map? = null + var semantics: WireObjectsMapSemantics? = null + var entries: Map? = null var clearTimeserial: String? = null for (i in 0 until fieldCount) { @@ -690,13 +697,13 @@ private fun readObjectMap(unpacker: MessageUnpacker): ObjectsMap { when (fieldName) { "semantics" -> { val semanticsCode = unpacker.unpackInt() - semantics = ObjectsMapSemantics.entries.firstOrNull { it.code == semanticsCode } - ?: ObjectsMapSemantics.entries.firstOrNull { it.code == -1 } - ?: throw objectError("Unknown MapSemantics code: $semanticsCode and no UNKNOWN fallback found") + semantics = WireObjectsMapSemantics.entries.firstOrNull { it.code == semanticsCode } + ?: WireObjectsMapSemantics.entries.firstOrNull { it.code == -1 } + ?: throw objectStateError("Unknown MapSemantics code: $semanticsCode and no UNKNOWN fallback found") } "entries" -> { val mapSize = unpacker.unpackMapHeader() - val tempMap = mutableMapOf() + val tempMap = mutableMapOf() for (j in 0 until mapSize) { val key = unpacker.unpackString() val value = readObjectMapEntry(unpacker) @@ -709,13 +716,13 @@ private fun readObjectMap(unpacker: MessageUnpacker): ObjectsMap { } } - return ObjectsMap(semantics = semantics, entries = entries, clearTimeserial = clearTimeserial) + return WireObjectsMap(semantics = semantics, entries = entries, clearTimeserial = clearTimeserial) } /** * Write ObjectCounter to MessagePacker */ -private fun ObjectsCounter.writeMsgpack(packer: MessagePacker) { +private fun WireObjectsCounter.writeMsgpack(packer: MessagePacker) { var fieldCount = 0 if (count != null) fieldCount++ @@ -731,7 +738,7 @@ private fun ObjectsCounter.writeMsgpack(packer: MessagePacker) { /** * Read ObjectCounter from MessageUnpacker */ -private fun readObjectCounter(unpacker: MessageUnpacker): ObjectsCounter { +private fun readObjectCounter(unpacker: MessageUnpacker): WireObjectsCounter { val fieldCount = unpacker.unpackMapHeader() var count: Double? = null @@ -751,13 +758,13 @@ private fun readObjectCounter(unpacker: MessageUnpacker): ObjectsCounter { } } - return ObjectsCounter(count = count) + return WireObjectsCounter(count = count) } /** * Write ObjectMapEntry to MessagePacker */ -private fun ObjectsMapEntry.writeMsgpack(packer: MessagePacker) { +private fun WireObjectsMapEntry.writeMsgpack(packer: MessagePacker) { var fieldCount = 0 if (tombstone != null) fieldCount++ @@ -791,13 +798,13 @@ private fun ObjectsMapEntry.writeMsgpack(packer: MessagePacker) { /** * Read ObjectMapEntry from MessageUnpacker */ -private fun readObjectMapEntry(unpacker: MessageUnpacker): ObjectsMapEntry { +private fun readObjectMapEntry(unpacker: MessageUnpacker): WireObjectsMapEntry { val fieldCount = unpacker.unpackMapHeader() var tombstone: Boolean? = null var timeserial: String? = null var serialTimestamp: Long? = null - var data: ObjectData? = null + var data: WireObjectData? = null for (i in 0 until fieldCount) { val fieldName = unpacker.unpackString().intern() @@ -817,13 +824,13 @@ private fun readObjectMapEntry(unpacker: MessageUnpacker): ObjectsMapEntry { } } - return ObjectsMapEntry(tombstone = tombstone, timeserial = timeserial, serialTimestamp = serialTimestamp, data = data) + return WireObjectsMapEntry(tombstone = tombstone, timeserial = timeserial, serialTimestamp = serialTimestamp, data = data) } /** - * Write ObjectData to MessagePacker + * Write WireObjectData to MessagePacker */ -private fun ObjectData.writeMsgpack(packer: MessagePacker) { +private fun WireObjectData.writeMsgpack(packer: MessagePacker) { var fieldCount = 0 if (objectId != null) fieldCount++ @@ -869,9 +876,9 @@ private fun ObjectData.writeMsgpack(packer: MessagePacker) { } /** - * Read ObjectData from MessageUnpacker + * Read WireObjectData from MessageUnpacker */ -private fun readObjectData(unpacker: MessageUnpacker): ObjectData { +private fun readObjectData(unpacker: MessageUnpacker): WireObjectData { val fieldCount = unpacker.unpackMapHeader() var objectId: String? = null var string: String? = null @@ -905,5 +912,5 @@ private fun readObjectData(unpacker: MessageUnpacker): ObjectData { } } - return ObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json) + return WireObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json) } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveCounter.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveCounter.kt new file mode 100644 index 000000000..bbeeb60ea --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveCounter.kt @@ -0,0 +1,22 @@ +package io.ably.lib.liveobjects.value + +/** + * Default implementation of the [LiveCounter] value type - an immutable holder for + * the initial count of a LiveCounter object to be created. Mirrors ably-js + * `LiveCounterValueType`. + * + * Instantiated reflectively by [LiveCounter.create] through the constructor that + * takes the initial count; the count is retained internally with no public accessor + * (Spec: RTLCV3d). + * + * This is currently a skeleton: it only retains the initial value. Producing the + * `COUNTER_CREATE` operation/message from this count is not yet implemented. + * + * Spec: RTLCV1, RTLCV2, RTLCV3 + */ +internal class DefaultLiveCounter( + internal val initialCount: Number, +) : LiveCounter() { + // TODO - build the COUNTER_CREATE ObjectMessage from `initialCount`, mirroring + // ably-js LiveCounterValueType.createCounterCreateMessage. Spec: RTO12f +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveMap.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveMap.kt new file mode 100644 index 000000000..b0fcd9abb --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveMap.kt @@ -0,0 +1,24 @@ +package io.ably.lib.liveobjects.value + +/** + * Default implementation of the [LiveMap] value type - an immutable holder for the + * initial entries of a LiveMap object to be created. Mirrors ably-js + * `LiveMapValueType`. + * + * Instantiated reflectively by [LiveMap.create] through the constructor that takes + * the initial entries map; the entries are retained internally with no public + * accessor (Spec: RTLMV3d). + * + * This is currently a skeleton: it only retains the initial value. Producing the + * `MAP_CREATE` operation/message from these entries (including nested object create + * messages for nested [LiveMap]/[LiveCounter] value types) is not yet implemented. + * + * Spec: RTLMV1, RTLMV2, RTLMV3 + */ +internal class DefaultLiveMap( + internal val entries: Map, +) : LiveMap() { + // TODO - build the MAP_CREATE ObjectMessage (plus nested object create messages) + // from `entries`, mirroring ably-js LiveMapValueType.createMapCreateMessage. + // Spec: RTO11f +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/ResolvedValue.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/ResolvedValue.kt new file mode 100644 index 000000000..34002837d --- /dev/null +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/ResolvedValue.kt @@ -0,0 +1,39 @@ +package io.ably.lib.liveobjects.value + +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.message.WireObjectData + +/** + * The result of resolving a path segment / map entry against the objects + * graph: either a node view of a live object, or a primitive leaf carried as + * wire ObjectData. + */ +internal sealed interface ResolvedValue { + data class MapRef(val map: LiveMap) : ResolvedValue // TODO: LiveMap will be replaced by InternalLiveMap + data class CounterRef(val counter: LiveCounter) : ResolvedValue // TODO: LiveCounter will be replaced by InternalLiveCounter + data class Leaf(val data: WireObjectData) : ResolvedValue +} + +/** + * Maps a resolved value to the public ValueType enum. + * + * Only ever invoked on a value that resolved to something - absence at a path is + * represented by a `null` [ResolvedValue] and surfaced as a `null` type by the + * caller, never as [ValueType.UNKNOWN]. UNKNOWN is reserved for a value that is + * present but matches none of the known categories. + * + * Spec: RTTS2a, RTTS4b3 + */ +internal fun ResolvedValue.valueType(): ValueType = when (this) { + is ResolvedValue.MapRef -> ValueType.LIVE_MAP + is ResolvedValue.CounterRef -> ValueType.LIVE_COUNTER + is ResolvedValue.Leaf -> when { + data.string != null -> ValueType.STRING + data.number != null -> ValueType.NUMBER + data.boolean != null -> ValueType.BOOLEAN + data.bytes != null -> ValueType.BINARY + data.json?.isJsonObject == true -> ValueType.JSON_OBJECT + data.json?.isJsonArray == true -> ValueType.JSON_ARRAY + else -> ValueType.UNKNOWN + } +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt deleted file mode 100644 index 786eb594b..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt +++ /dev/null @@ -1,35 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.realtime.ChannelState -import io.ably.lib.types.ProtocolMessage -import java.util.concurrent.ConcurrentHashMap - -public class DefaultLiveObjectsPlugin(private val adapter: ObjectsAdapter) : LiveObjectsPlugin { - - private val objects = ConcurrentHashMap() - - override fun getInstance(channelName: String): RealtimeObjects { - return objects.getOrPut(channelName) { DefaultRealtimeObjects(channelName, adapter) } - } - - override fun handle(msg: ProtocolMessage) { - val channelName = msg.channel - objects[channelName]?.handle(msg) - } - - override fun handleStateChange(channelName: String, state: ChannelState, hasObjects: Boolean) { - objects[channelName]?.handleStateChange(state, hasObjects) - } - - override fun dispose(channelName: String) { - objects.remove(channelName) - ?.dispose(clientError("Channel has been released using channels.release()")) - } - - override fun dispose() { - objects.values.forEach { - it.dispose(clientError("AblyClient has been closed using client.close()")) - } - objects.clear() - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt deleted file mode 100644 index 617388fb6..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt +++ /dev/null @@ -1,372 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.objects.serialization.gson -import io.ably.lib.objects.state.ObjectsStateChange -import io.ably.lib.objects.state.ObjectsStateEvent -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.objects.type.counter.LiveCounter -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.type.map.LiveMap -import io.ably.lib.objects.type.map.LiveMapValue -import io.ably.lib.realtime.ChannelState -import io.ably.lib.types.AblyException -import io.ably.lib.types.ProtocolMessage -import io.ably.lib.types.PublishResult -import io.ably.lib.util.Clock -import io.ably.lib.util.Log -import io.ably.lib.util.SystemClock -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED -import kotlinx.coroutines.flow.MutableSharedFlow -import java.util.concurrent.CancellationException - -/** - * Default implementation of RealtimeObjects interface. - * Provides the core functionality for managing objects on a channel. - */ -internal class DefaultRealtimeObjects(internal val channelName: String, internal val adapter: ObjectsAdapter): RealtimeObjects { - private val tag = "DefaultRealtimeObjects" - /** - * @spec RTO3 - Objects pool storing all objects by object ID - */ - internal val objectsPool = ObjectsPool(this) - - internal var state = ObjectsState.Initialized - - /** - * Set of serials for operations applied locally upon ACK, awaiting deduplication of the server echo. - * @spec RTO7b, RTO7b1 - */ - internal val appliedOnAckSerials = mutableSetOf() - - /** - * @spec RTO4 - Used for handling object messages and object sync messages - */ - private val objectsManager = ObjectsManager(this) - - /** - * Coroutine scope for running sequential operations on a single thread, used to avoid concurrency issues. - */ - private val sequentialScope = - CoroutineScope(Dispatchers.Default.limitedParallelism(1) + CoroutineName(channelName) + SupervisorJob()) - - /** - * Event bus for handling incoming object messages sequentially. - * Processes messages inside [incomingObjectsHandler] job created using [sequentialScope]. - */ - private val objectsEventBus = MutableSharedFlow(extraBufferCapacity = UNLIMITED) - private val incomingObjectsHandler: Job - - /** - * Provides a channel-specific scope for safely executing asynchronous operations with callbacks. - */ - internal val asyncScope = ObjectsAsyncScope(channelName) - - init { - incomingObjectsHandler = initializeHandlerForIncomingObjectMessages() - } - - override fun getRoot(): LiveMap = runBlocking { getRootAsync() } - - override fun createMap(): LiveMap = createMap(mutableMapOf()) - - override fun createMap(entries: MutableMap): LiveMap = runBlocking { createMapAsync(entries) } - - override fun createCounter(): LiveCounter = createCounter(0) - - override fun createCounter(initialValue: Number): LiveCounter = runBlocking { createCounterAsync(initialValue) } - - override fun getRootAsync(callback: ObjectsCallback) { - asyncScope.launchWithCallback(callback) { getRootAsync() } - } - - override fun createMapAsync(callback: ObjectsCallback) = createMapAsync(mutableMapOf(), callback) - - override fun createMapAsync(entries: MutableMap, callback: ObjectsCallback) { - asyncScope.launchWithCallback(callback) { createMapAsync(entries) } - } - - override fun createCounterAsync(callback: ObjectsCallback) = createCounterAsync(0, callback) - - override fun createCounterAsync(initialValue: Number, callback: ObjectsCallback) { - asyncScope.launchWithCallback(callback) { createCounterAsync(initialValue) } - } - - override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription = - objectsManager.on(event, listener) - - override fun off(listener: ObjectsStateChange.Listener) = objectsManager.off(listener) - - override fun offAll() = objectsManager.offAll() - - private suspend fun getRootAsync(): LiveMap = withContext(sequentialScope.coroutineContext) { - adapter.throwIfInvalidAccessApiConfiguration(channelName) - adapter.ensureAttached(channelName) - objectsManager.ensureSynced(state) - objectsPool.get(ROOT_OBJECT_ID) as LiveMap - } - - private suspend fun createMapAsync(entries: MutableMap): LiveMap { - adapter.throwIfInvalidWriteApiConfiguration(channelName) // RTO11c, RTO11d, RTO11e - - if (entries.keys.any { it.isEmpty() }) { // RTO11f2 - throw invalidInputError("Map keys should not be empty") - } - - // RTO11f14 - Create initial value operation - val initialMapValue = DefaultLiveMap.initialValue(entries) - - // RTO11f15 - Create initial value JSON string - val initialValueJSONString = gson.toJson(initialMapValue) - - // RTO11f8 - Create object ID from initial value - val (objectId, nonce) = getObjectIdStringWithNonce(ObjectType.Map, initialValueJSONString) - - // Create ObjectMessage with the operation - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = objectId, - mapCreateWithObjectId = MapCreateWithObjectId( - nonce = nonce, - initialValue = initialValueJSONString, - derivedFrom = initialMapValue, - ), - ) - ) - - // RTO11i - publish and apply locally on ACK - publishAndApply(arrayOf(msg)) - - // RTO11h2 - Return existing object if found after apply - return objectsPool.get(objectId) as? LiveMap - ?: throw serverError("createMap: MAP_CREATE was not applied as expected; objectId=$objectId") // RTO11h3d - } - - private suspend fun createCounterAsync(initialValue: Number): LiveCounter { - adapter.throwIfInvalidWriteApiConfiguration(channelName) // RTO12c, RTO12d, RTO12e - - // Validate input parameter - if (initialValue.toDouble().isNaN() || initialValue.toDouble().isInfinite()) { - throw invalidInputError("Counter value should be a valid number") - } - - // RTO12f12 - val initialCounterValue = DefaultLiveCounter.initialValue(initialValue) - // RTO12f13 - Create initial value operation - val initialValueJSONString = gson.toJson(initialCounterValue) - - // RTO12f6- Create object ID from initial value - val (objectId, nonce) = getObjectIdStringWithNonce(ObjectType.Counter, initialValueJSONString) - - // Create ObjectMessage with the operation - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = objectId, - counterCreateWithObjectId = CounterCreateWithObjectId( - nonce = nonce, - initialValue = initialValueJSONString, - derivedFrom = initialCounterValue, - ), - ) - ) - - // RTO12i - publish and apply locally on ACK - publishAndApply(arrayOf(msg)) - - // RTO12h2 - Return existing object if found after apply - return objectsPool.get(objectId) as? LiveCounter - ?: throw serverError("createCounter: COUNTER_CREATE was not applied as expected; objectId=$objectId") // RTO12h3d - } - - /** - * Spec: RTO11f8, RTO12f6 - */ - private suspend fun getObjectIdStringWithNonce(objectType: ObjectType, initialValue: String): Pair { - val nonce = generateNonce() - val msTimestamp = ServerTime.getCurrentTime(adapter) // RTO16 - Get server time for nonce generation - return Pair(ObjectId.fromInitialValue(objectType, initialValue, nonce, msTimestamp).toString(), nonce) - } - - /** - * Spec: RTO15 - */ - internal suspend fun publish(objectMessages: Array): PublishResult { - // RTO15b, RTL6c - Ensure that the channel is in a valid state for publishing - adapter.throwIfUnpublishableState(channelName) - adapter.ensureMessageSizeWithinLimit(objectMessages) - // RTO15e - Must construct the ProtocolMessage as per RTO15e1, RTO15e2, RTO15e3 - val protocolMessage = ProtocolMessage(ProtocolMessage.Action.`object`, channelName) - protocolMessage.state = objectMessages - // RTO15f, RTO15g - Send the ProtocolMessage using the adapter and capture success/failure - return adapter.sendAsync(protocolMessage) // RTO15h - } - - /** - * Publishes the given object messages and, upon receiving the ACK, immediately applies them - * locally as synthetic inbound messages using the assigned serial and connection's siteCode. - * - * Spec: RTO20 - */ - internal suspend fun publishAndApply(objectMessages: Array) { - // RTO20b - publish, propagate failure - val publishResult = publish(objectMessages) - - // RTO20c - validate required info - val siteCode = adapter.connectionManager.siteCode - if (siteCode == null) { - Log.e(tag, "RTO20c1: siteCode not available; operations will be applied when echoed") - return - } - val serials = publishResult.serials - if (serials == null || serials.size != objectMessages.size) { - Log.e(tag, "RTO20c2: PublishResult.serials unavailable or wrong length; operations will be applied when echoed") - return - } - - // RTO20d - create synthetic inbound ObjectMessages - val syntheticMessages = mutableListOf() - objectMessages.forEachIndexed { i, msg -> - val serial = serials[i] - if (serial == null) { - Log.d(tag, "RTO20d1: serial null at index $i (conflated), skipping") - return@forEachIndexed - } - syntheticMessages.add(msg.copy(serial = serial, siteCode = siteCode)) // RTO20d2a, RTO20d2b, RTO20d3 - } - if (syntheticMessages.isEmpty()) return - - // RTO20e, RTO20f - dispatch to sequential scope for ordering - withContext(sequentialScope.coroutineContext) { - objectsManager.applyAckResult(syntheticMessages) // suspends if SYNCING (RTO20e), applies on SYNCED (RTO20f) - } - } - - /** - * Handles a ProtocolMessage containing proto action as `object` or `object_sync`. - * @spec RTL1 - Processes incoming object messages and object sync messages - */ - internal fun handle(protocolMessage: ProtocolMessage) { - // RTL15b - Set channel serial for OBJECT messages - adapter.setChannelSerial(channelName, protocolMessage) - - if (protocolMessage.state == null || protocolMessage.state.isEmpty()) { - Log.w(tag, "Received ProtocolMessage with null or empty objects, ignoring") - return - } - - objectsEventBus.tryEmit(protocolMessage) - } - - /** - * Initializes the handler for incoming object messages and object sync messages. - * Processes the messages sequentially to ensure thread safety and correct order of operations. - * - * @spec OM2 - Populates missing fields from parent protocol message - */ - private fun initializeHandlerForIncomingObjectMessages(): Job { - return sequentialScope.launch { - objectsEventBus.collect { protocolMessage -> - // OM2 - Populate missing fields from parent - val objects = protocolMessage.state.filterIsInstance() - .mapIndexed { index, objMsg -> - objMsg.copy( - connectionId = objMsg.connectionId ?: protocolMessage.connectionId, // OM2c - timestamp = objMsg.timestamp ?: protocolMessage.timestamp, // OM2e - id = objMsg.id ?: (protocolMessage.id + ':' + index) // OM2a - ) - } - - try { - when (protocolMessage.action) { - ProtocolMessage.Action.`object` -> objectsManager.handleObjectMessages(objects) - ProtocolMessage.Action.object_sync -> objectsManager.handleObjectSyncMessages( - objects, - protocolMessage.channelSerial - ) - else -> Log.w(tag, "Ignoring protocol message with unhandled action: ${protocolMessage.action}") - } - } catch (exception: Exception) { - // Skip current message if an error occurs, don't rethrow to avoid crashing the collector - Log.e(tag, "Error handling objects message with protocolMsg id ${protocolMessage.id}", exception) - } - } - } - } - - internal fun handleStateChange(state: ChannelState, hasObjects: Boolean) { - sequentialScope.launch { - when (state) { - ChannelState.attached -> { - Log.v(tag, "Objects.onAttached() channel=$channelName, hasObjects=$hasObjects") - - objectsManager.clearBufferedObjectOperations() // RTO4d - clear unconditionally on ATTACHED - - // RTO4a - val fromInitializedState = this@DefaultRealtimeObjects.state == ObjectsState.Initialized - if (hasObjects || fromInitializedState) { - // should always start a new sync sequence if we're in the initialized state, no matter the HAS_OBJECTS flag value. - // this guarantees we emit both "syncing" -> "synced" events in that order. - objectsManager.startNewSync(null) - } - - // RTO4b - if (!hasObjects) { - // if no HAS_OBJECTS flag received on attach, we can end sync sequence immediately and treat it as no objects on a channel. - // reset the objects pool to its initial state, and emit update events so subscribers to root object get notified about changes. - objectsPool.resetToInitialPool(true) // RTO4b1, RTO4b2 - objectsManager.clearSyncObjectsPool() // RTO4b3 - // RTO4b5 removed — buffer already cleared by RTO4d above - // defer the state change event until the next tick if we started a new sequence just now due to being in initialized state. - // this allows any event listeners to process the start of the new sequence event that was emitted earlier during this event loop. - objectsManager.endSync() // RTO4b4 - } - } - ChannelState.detached, - ChannelState.suspended, - ChannelState.failed -> { - val errorReason = try { - adapter.getChannel(channelName).reason - } catch (e: Exception) { - null - } - val error = ablyException( - "publishAndApply could not be applied locally: channel entered $state whilst waiting for objects sync", - ErrorCode.PublishAndApplyFailedDueToChannelState, - HttpStatusCode.BadRequest, - cause = errorReason?.let { AblyException.fromErrorInfo(it) } - ) - objectsManager.failBufferedAcks(error) // RTO20e1 - if (state != ChannelState.suspended) { - // do not emit data update events as the actual current state of Objects data is unknown when we're in these channel states - objectsPool.clearObjectsData(false) - objectsManager.clearSyncObjectsPool() - } - } - else -> { - // No action needed for other states - } - } - } - } - - // Dispose of any resources associated with this RealtimeObjects instance - fun dispose(cause: AblyException) { - val disposeReason = CancellationException().apply { initCause(cause) } - incomingObjectsHandler.cancel(disposeReason) // objectsEventBus automatically garbage collected when collector is cancelled - objectsPool.dispose() - objectsManager.dispose() - // Don't cancel sequentialScope (needed in getRoot method), just cancel ongoing coroutines - sequentialScope.coroutineContext.cancelChildren(disposeReason) - asyncScope.cancel(disposeReason) // cancel all ongoing callbacks - } -} - -/** - * Provides the default Clock instance for the DefaultRealtimeObjects. - * This Clock is derived from the system clock, utilizing the client options - * from the adapter configuration. - */ -internal val DefaultRealtimeObjects.clock get(): Clock = SystemClock.clockFrom(adapter.clientOptions) diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt deleted file mode 100644 index 1a8d1b8ad..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.ably.lib.objects - -internal enum class ErrorCode(public val code: Int) { - BadRequest(40_000), - InternalError(50_000), - MaxMessageSizeExceeded(40_009), - InvalidObject(92_000), - // LiveMap specific error codes - InvalidInputParams(40_003), - MapValueDataTypeUnsupported(40_013), - // Channel mode and state validation error codes - ChannelModeRequired(40_024), - ChannelStateError(90_001), - PublishAndApplyFailedDueToChannelState(92_008), -} - -internal enum class HttpStatusCode(public val code: Int) { - BadRequest(400), - InternalServerError(500), -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/Helpers.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/Helpers.kt deleted file mode 100644 index 683971510..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/Helpers.kt +++ /dev/null @@ -1,178 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.realtime.ChannelState -import io.ably.lib.realtime.CompletionListener -import io.ably.lib.types.Callback -import io.ably.lib.realtime.ConnectionEvent -import io.ably.lib.realtime.ConnectionStateListener -import io.ably.lib.types.ChannelMode -import io.ably.lib.types.ErrorInfo -import io.ably.lib.types.ProtocolMessage -import io.ably.lib.types.PublishResult -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -internal val ObjectsAdapter.connectionManager get() = connection.connectionManager - -/** - * Spec: RTO15g - */ -internal suspend fun ObjectsAdapter.sendAsync(message: ProtocolMessage): PublishResult = suspendCancellableCoroutine { continuation -> - try { - connectionManager.send(message, clientOptions.queueMessages, object : Callback { - override fun onSuccess(result: PublishResult) { - continuation.resume(result) - } - - override fun onError(reason: ErrorInfo) { - continuation.resumeWithException(ablyException(reason)) - } - }) - } catch (e: Exception) { - continuation.resumeWithException(e) - } -} - -internal suspend fun ObjectsAdapter.attachAsync(channelName: String) = suspendCancellableCoroutine { continuation -> - try { - getChannel(channelName).attach(object : CompletionListener { - override fun onSuccess() { - continuation.resume(Unit) - } - - override fun onError(reason: ErrorInfo) { - continuation.resumeWithException(ablyException(reason)) - } - }) - } catch (e: Exception) { - continuation.resumeWithException(e) - } -} - -internal fun ObjectsAdapter.onGCGracePeriodUpdated(block : (Long?) -> Unit) : ObjectsSubscription { - connectionManager.objectsGCGracePeriod?.let { block(it) } - // Return new objectsGCGracePeriod whenever connection state changes to connected - val listener: (_: ConnectionStateListener.ConnectionStateChange) -> Unit = { - block(connectionManager.objectsGCGracePeriod) - } - connection.on(ConnectionEvent.connected, listener) - return ObjectsSubscription { connection.off(listener) } -} - -/** - * Retrieves the channel modes for a specific channel. - * This method returns the modes that are set for the specified channel. - * - * @param channelName the name of the channel for which to retrieve the modes - * @return the array of channel modes for the specified channel, or null if the channel is not found - * Spec: RTO2a, RTO2b - */ -internal fun ObjectsAdapter.getChannelModes(channelName: String): Array? { - val channel = getChannel(channelName) - - // RTO2a - channel.modes is only populated on channel attachment, so use it only if it is set - channel.modes?.let { modes -> - if (modes.isNotEmpty()) { - return modes - } - } - - // RTO2b - otherwise as a best effort use user provided channel options - channel.options?.let { options -> - if (options.hasModes()) { - return options.modes - } - } - return null -} - -/** - * Spec: RTO15d - */ -internal fun ObjectsAdapter.ensureMessageSizeWithinLimit(objectMessages: Array) { - val maximumAllowedSize = connectionManager.maxMessageSize - val objectsTotalMessageSize = objectMessages.sumOf { it.size() } - if (objectsTotalMessageSize > maximumAllowedSize) { - throw ablyException("ObjectMessages size $objectsTotalMessageSize exceeds maximum allowed size of $maximumAllowedSize bytes", - ErrorCode.MaxMessageSizeExceeded) - } -} - -internal fun ObjectsAdapter.setChannelSerial(channelName: String, protocolMessage: ProtocolMessage) { - if (protocolMessage.action != ProtocolMessage.Action.`object`) return - val channelSerial = protocolMessage.channelSerial - if (channelSerial.isNullOrEmpty()) return - getChannel(channelName).properties.channelSerial = channelSerial -} - -internal suspend fun ObjectsAdapter.ensureAttached(channelName: String) { - val channel = getChannel(channelName) - when (val currentChannelStatus = channel.state) { - ChannelState.initialized -> attachAsync(channelName) - ChannelState.attached -> return - ChannelState.attaching -> { - val attachDeferred = CompletableDeferred() - getChannel(channelName).once { - when(it.current) { - ChannelState.attached -> attachDeferred.complete(Unit) - else -> { - val exception = ablyException("Channel $channelName is in invalid state: ${it.current}, " + - "error: ${it.reason}", ErrorCode.ChannelStateError) - attachDeferred.completeExceptionally(exception) - } - } - } - if (channel.state == ChannelState.attached) { - attachDeferred.complete(Unit) - } - attachDeferred.await() - } - else -> - throw ablyException("Channel $channelName is in invalid state: $currentChannelStatus", ErrorCode.ChannelStateError) - } -} - -// Spec: RTLO4b1, RTLO4b2 -internal fun ObjectsAdapter.throwIfInvalidAccessApiConfiguration(channelName: String) { - throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed)) - throwIfMissingChannelMode(channelName, ChannelMode.object_subscribe) -} - -internal fun ObjectsAdapter.throwIfInvalidWriteApiConfiguration(channelName: String) { - throwIfEchoMessagesDisabled() - throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed, ChannelState.suspended)) - throwIfMissingChannelMode(channelName, ChannelMode.object_publish) -} - -internal fun ObjectsAdapter.throwIfUnpublishableState(channelName: String) { - if (!connectionManager.isActive) { - throw ablyException(connectionManager.stateErrorInfo) - } - throwIfInChannelState(channelName, arrayOf(ChannelState.failed, ChannelState.suspended)) -} - -// Spec: RTO2 -private fun ObjectsAdapter.throwIfMissingChannelMode(channelName: String, channelMode: ChannelMode) { - val channelModes = getChannelModes(channelName) - if (channelModes == null || !channelModes.contains(channelMode)) { - // Spec: RTO2a2, RTO2b2 - throw ablyException("\"${channelMode.name}\" channel mode must be set for this operation", ErrorCode.ChannelModeRequired) - } -} - -private fun ObjectsAdapter.throwIfInChannelState(channelName: String, channelStates: Array) { - val currentState = getChannel(channelName).state - if (currentState == null || channelStates.contains(currentState)) { - throw ablyException("Channel is in invalid state: $currentState", ErrorCode.ChannelStateError) - } -} - -internal fun ObjectsAdapter.throwIfEchoMessagesDisabled() { - if (!clientOptions.echoMessages) { - throw clientError("\"echoMessages\" client option must be enabled for this operation") - } -} - - diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt deleted file mode 100644 index 64a040ddc..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt +++ /dev/null @@ -1,79 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.objects.type.ObjectType -import java.nio.charset.StandardCharsets -import java.security.MessageDigest -import java.util.Base64 - -internal class ObjectId private constructor( - internal val type: ObjectType, - private val hash: String, - private val timestampMs: Long -) { - /** - * Converts ObjectId to string representation. - * Spec: RTO6b1 - */ - override fun toString(): String { - return "${type.value}:$hash@$timestampMs" - } - - companion object { - - /** - * Spec: RTO14 - */ - internal fun fromInitialValue(objectType: ObjectType, initialValue: String, nonce: String, msTimeStamp: Long): ObjectId { - val valueForHash = "$initialValue:$nonce".toByteArray(StandardCharsets.UTF_8) - // RTO14b - Hash the initial value and nonce to create a unique identifier - val hashBytes = MessageDigest.getInstance("SHA-256").digest(valueForHash) - val urlSafeHash = Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes) - - return ObjectId(objectType, urlSafeHash, msTimeStamp) - } - - /** - * Creates ObjectId instance from hashed object id string. - */ - internal fun fromString(objectId: String): ObjectId { - if (objectId.isEmpty()) { - throw objectError("Invalid object id: $objectId") - } - - // Parse format: type:hash@msTimestamp - val parts = objectId.split(':') - if (parts.size != 2) { - throw objectError("Invalid object id: $objectId") - } - - val (typeStr, rest) = parts - - val type = when (typeStr) { - "map" -> ObjectType.Map - "counter" -> ObjectType.Counter - else -> throw objectError("Invalid object type in object id: $objectId") - } - - val hashAndTimestamp = rest.split('@') - if (hashAndTimestamp.size != 2) { - throw objectError("Invalid object id: $objectId") - } - - val hash = hashAndTimestamp[0] - - if (hash.isEmpty()) { - throw objectError("Invalid object id: $objectId") - } - - val msTimestampStr = hashAndTimestamp[1] - - val msTimestamp = try { - msTimestampStr.toLong() - } catch (e: NumberFormatException) { - throw objectError("Invalid object id: $objectId", e) - } - - return ObjectId(type, hash, msTimestamp) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt deleted file mode 100644 index 7f3e9b372..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt +++ /dev/null @@ -1,545 +0,0 @@ -package io.ably.lib.objects - -import com.google.gson.JsonElement -import com.google.gson.JsonObject - -import com.google.gson.annotations.JsonAdapter -import com.google.gson.annotations.SerializedName -import io.ably.lib.objects.serialization.ObjectDataJsonSerializer -import io.ably.lib.objects.serialization.gson -import java.util.Base64 - -/** - * An enum class representing the different actions that can be performed on an object. - * Spec: OOP2 - */ -internal enum class ObjectOperationAction(val code: Int) { - MapCreate(0), - MapSet(1), - MapRemove(2), - CounterCreate(3), - CounterInc(4), - ObjectDelete(5), - MapClear(6), - Unknown(-1); // code for unknown value during deserialization -} - -/** - * An enum class representing the conflict-resolution semantics used by a Map object. - * Spec: OMP2 - */ -internal enum class ObjectsMapSemantics(val code: Int) { - LWW(0), - Unknown(-1); // code for unknown value during deserialization -} - -/** - * An ObjectData represents a value in an object on a channel. - * Spec: OD1 - */ -@JsonAdapter(ObjectDataJsonSerializer::class) -internal data class ObjectData( - /** - * A reference to another object, used to support composable object structures. - * Spec: OD2a - */ - val objectId: String? = null, - - /** String value. Spec: OD2c */ - val string: String? = null, - - /** Numeric value. Spec: OD2c */ - val number: Double? = null, - - /** Boolean value. Spec: OD2c */ - val boolean: Boolean? = null, - - /** Binary value encoded as a base64 string. Spec: OD2c */ - val bytes: String? = null, - - /** JSON object or array value. Spec: OD2c */ - val json: JsonElement? = null, -) - -/** - * Payload for MAP_CREATE operation. - * Spec: MCR* - */ -internal data class MapCreate( - val semantics: ObjectsMapSemantics, // MCR2a - val entries: Map // MCR2b -) - -/** - * Payload for MAP_SET operation. - * Spec: MST* - */ -internal data class MapSet( - val key: String, // MST2a - val value: ObjectData // MST2b - REQUIRED -) - -/** - * Payload for MAP_REMOVE operation. - * Spec: MRM* - */ -internal data class MapRemove( - val key: String // MRM2a -) - -/** - * Payload for COUNTER_CREATE operation. - * Spec: CCR* - */ -internal data class CounterCreate( - val count: Double // CCR2a - REQUIRED -) - -/** - * Payload for COUNTER_INC operation. - * Spec: CIN* - */ -internal data class CounterInc( - val number: Double // CIN2a - REQUIRED -) - -/** - * Payload for OBJECT_DELETE operation. - * Spec: ODE* - * No fields - action is sufficient - */ -internal object ObjectDelete - -/** - * Payload for MAP_CLEAR operation. - * Spec: MCL* - * No fields - action is sufficient - */ -internal object MapClear - -/** - * Payload for MAP_CREATE_WITH_OBJECT_ID operation. - * Spec: MCRO* - */ -internal data class MapCreateWithObjectId( - val initialValue: String, // MCRO2a - val nonce: String, // MCRO2b - @Transient val derivedFrom: MapCreate? = null, -) - -/** - * Payload for COUNTER_CREATE_WITH_OBJECT_ID operation. - * Spec: CCRO* - */ -internal data class CounterCreateWithObjectId( - val initialValue: String, // CCRO2a - val nonce: String, // CCRO2b - @Transient val derivedFrom: CounterCreate? = null, -) - -/** - * A MapEntry represents the value at a given key in a Map object. - * Spec: ME1 - */ -internal data class ObjectsMapEntry( - /** - * Indicates whether the map entry has been removed. - * Spec: OME2a - */ - val tombstone: Boolean? = null, - - /** - * The serial value of the latest operation that was applied to the map entry. - * It is optional in a MAP_CREATE operation and might be missing, in which case the client should use a null value for it - * and treat it as the "earliest possible" serial for comparison purposes. - * Spec: OME2b - */ - val timeserial: String? = null, - - /** - * A timestamp from the [timeserial] field. Only present if [tombstone] is `true` - * Spec: OME2d - */ - val serialTimestamp: Long? = null, - - /** - * The data that represents the value of the map entry. - * Spec: OME2c - */ - val data: ObjectData? = null -) - -/** - * An ObjectMap object represents a map of key-value pairs. - * Spec: OMP1 - */ -internal data class ObjectsMap( - /** - * The conflict-resolution semantics used by the map object. - * Spec: OMP3a - */ - val semantics: ObjectsMapSemantics? = null, - - /** - * The map entries, indexed by key. - * Spec: OMP3b - */ - val entries: Map? = null, - - /** - * The serial value of the last MAP_CLEAR operation applied to the map. - * Spec: OMP3c - */ - val clearTimeserial: String? = null, -) - -/** - * An ObjectCounter object represents an incrementable and decrementable value - * Spec: OCN1 - */ -internal data class ObjectsCounter( - /** - * The value of the counter - * Spec: OCN2a - */ - val count: Double? = null -) - -/** - * An ObjectOperation describes an operation to be applied to an object on a channel. - * Spec: OOP1 - */ -internal data class ObjectOperation( - /** - * Defines the operation to be applied to the object. - * Spec: OOP3a - */ - val action: ObjectOperationAction, - - /** - * The object ID of the object on a channel to which the operation should be applied. - * Spec: OOP3b - */ - val objectId: String, - - /** - * Payload for MAP_CREATE operation. - * Spec: OOP3j - */ - val mapCreate: MapCreate? = null, - - /** - * Payload for MAP_SET operation. - * Spec: OOP3k - */ - val mapSet: MapSet? = null, - - /** - * Payload for MAP_REMOVE operation. - * Spec: OOP3l - */ - val mapRemove: MapRemove? = null, - - /** - * Payload for COUNTER_CREATE operation. - * Spec: OOP3m - */ - val counterCreate: CounterCreate? = null, - - /** - * Payload for COUNTER_INC operation. - * Spec: OOP3n - */ - val counterInc: CounterInc? = null, - - /** - * Payload for OBJECT_DELETE operation. - * Spec: OOP3o - */ - val objectDelete: ObjectDelete? = null, - - /** - * Payload for MAP_CREATE_WITH_OBJECT_ID operation. - * Spec: OOP3p - */ - val mapCreateWithObjectId: MapCreateWithObjectId? = null, - - /** - * Payload for COUNTER_CREATE_WITH_OBJECT_ID operation. - * Spec: OOP3q - */ - val counterCreateWithObjectId: CounterCreateWithObjectId? = null, - - /** - * Payload for MAP_CLEAR operation. - * Spec: OOP3r - */ - val mapClear: MapClear? = null, -) - -/** - * An ObjectState describes the instantaneous state of an object on a channel. - * Spec: OST1 - */ -internal data class ObjectState( - /** - * The identifier of the object. - * Spec: OST2a - */ - val objectId: String, - - /** - * A map of serials keyed by a {@link ObjectMessage.siteCode}, - * representing the last operations applied to this object - * Spec: OST2b - */ - val siteTimeserials: Map, - - /** - * True if the object has been tombstoned. - * Spec: OST2c - */ - val tombstone: Boolean, - - /** - * The operation that created the object. - * Can be missing if create operation for the object is not known at this point. - * Spec: OST2d - */ - val createOp: ObjectOperation? = null, - - /** - * The data that represents the result of applying all operations to a Map object - * excluding the initial value from the create operation if it is a Map object type. - * Spec: OST2e - */ - val map: ObjectsMap? = null, - - /** - * The data that represents the result of applying all operations to a Counter object - * excluding the initial value from the create operation if it is a Counter object type. - * Spec: OST2f - */ - val counter: ObjectsCounter? = null -) - -/** - * An @ObjectMessage@ represents an individual object message to be sent or received via the Ably Realtime service. - * Spec: OM1 - */ -internal data class ObjectMessage( - /** - * unique ID for this object message. This attribute is always populated for object messages received over REST. - * For object messages received over Realtime, if the object message does not contain an @id@, - * it should be set to @protocolMsgId:index@, where @protocolMsgId@ is the id of the @ProtocolMessage@ encapsulating it, - * and @index@ is the index of the object message inside the @state@ array of the @ProtocolMessage@ - * Spec: OM2a - */ - val id: String? = null, - - /** - * time in milliseconds since epoch. If an object message received from Ably does not contain a @timestamp@, - * it should be set to the @timestamp@ of the encapsulating @ProtocolMessage@ - * Spec: OM2e - */ - val timestamp: Long? = null, - - /** - * Spec: OM2b - */ - val clientId: String? = null, - - /** - * If an object message received from Ably does not contain a @connectionId@, - * it should be set to the @connectionId@ of the encapsulating @ProtocolMessage@ - * Spec: OM2c - */ - val connectionId: String? = null, - - /** - * JSON-encodable object, used to contain any arbitrary key value pairs which may also contain other primitive JSON types, - * JSON-encodable objects or JSON-encodable arrays. The @extras@ field is provided to contain message metadata and/or - * ancillary payloads in support of specific functionality. For 3.1 no specific functionality is specified for - * @extras@ in object messages. Unless otherwise specified, the client library should not attempt to do any filtering - * or validation of the @extras@ field itself, but should treat it opaquely, encoding it and passing it to realtime unaltered - * Spec: OM2d - */ - val extras: JsonObject? = null, - - /** - * Describes an operation to be applied to an object. - * Mutually exclusive with the `object` field. This field is only set on object messages if the `action` field of the - * `ProtocolMessage` encapsulating it is `OBJECT`. - * Spec: OM2f - */ - val operation: ObjectOperation? = null, - - /** - * Describes the instantaneous state of an object. - * Mutually exclusive with the `operation` field. This field is only set on object messages if the `action` field of - * the `ProtocolMessage` encapsulating it is `OBJECT_SYNC`. - * Spec: OM2g - */ - @SerializedName("object") - val objectState: ObjectState? = null, - - /** - * An opaque string that uniquely identifies this object message. - * Spec: OM2h - */ - val serial: String? = null, - - /** - * A timestamp from the [serial] field. - * Spec: OM2j - */ - val serialTimestamp: Long? = null, - - /** - * An opaque string used as a key to update the map of serial values on an object. - * Spec: OM2i - */ - val siteCode: String? = null -) - -/** - * Calculates the size of an ObjectMessage in bytes. - * Spec: OM3 - */ -internal fun ObjectMessage.size(): Int { - val clientIdSize = clientId?.byteSize ?: 0 // Spec: OM3f - val operationSize = operation?.size() ?: 0 // Spec: OM3b, OOP4 - val objectStateSize = objectState?.size() ?: 0 // Spec: OM3c, OST3 - val extrasSize = extras?.let { gson.toJson(it).length } ?: 0 // Spec: OM3d - - return clientIdSize + operationSize + objectStateSize + extrasSize -} - -/** - * Calculates the size of an ObjectOperation in bytes. - * Spec: OOP4 - */ -private fun ObjectOperation.size(): Int { - val mapCreateSize = mapCreate?.size() ?: mapCreateWithObjectId?.derivedFrom?.size() ?: 0 - val mapSetSize = mapSet?.size() ?: 0 - val mapRemoveSize = mapRemove?.size() ?: 0 - val counterCreateSize = counterCreate?.size() ?: counterCreateWithObjectId?.derivedFrom?.size() ?: 0 - val counterIncSize = counterInc?.size() ?: 0 - - return mapCreateSize + mapSetSize + mapRemoveSize + - counterCreateSize + counterIncSize -} - -/** - * Calculates the size of an ObjectState in bytes. - * Spec: OST3 - */ -private fun ObjectState.size(): Int { - val mapSize = map?.size() ?: 0 // Spec: OST3b, OMP4 - val counterSize = counter?.size() ?: 0 // Spec: OST3c, OCN3 - val createOpSize = createOp?.size() ?: 0 // Spec: OST3d, OOP4 - - return mapSize + counterSize + createOpSize -} - -/** - * Calculates the size of a MapCreate payload in bytes. - */ -private fun MapCreate.size(): Int { - return entries.entries.sumOf { it.key.byteSize + it.value.size() } -} - -/** - * Calculates the size of a MapSet payload in bytes. - */ -private fun MapSet.size(): Int { - return key.byteSize + value.size() -} - -/** - * Calculates the size of a MapRemove payload in bytes. - */ -private fun MapRemove.size(): Int { - return key.byteSize -} - -/** - * Calculates the size of a CounterCreate payload in bytes. - */ -private fun CounterCreate.size(): Int { - return 8 // Double is 8 bytes -} - -/** - * Calculates the size of a CounterInc payload in bytes. - */ -private fun CounterInc.size(): Int { - return 8 // Double is 8 bytes -} - -/** - * Calculates the size of a MapCreateWithObjectId payload in bytes. - */ -private fun MapCreateWithObjectId.size(): Int { - return initialValue.byteSize + nonce.byteSize -} - -/** - * Calculates the size of a CounterCreateWithObjectId payload in bytes. - */ -private fun CounterCreateWithObjectId.size(): Int { - return initialValue.byteSize + nonce.byteSize -} - -/** - * Calculates the size of an ObjectMap in bytes. - * Spec: OMP4 - */ -private fun ObjectsMap.size(): Int { - // Calculate the size of all map entries in the map property - val entriesSize = entries?.entries?.sumOf { - it.key.length + it.value.size() // // Spec: OMP4a1, OMP4a2 - } ?: 0 - - return entriesSize -} - -/** - * Calculates the size of an ObjectCounter in bytes. - * Spec: OCN3 - */ -private fun ObjectsCounter.size(): Int { - // Size is 8 if count is a number, 0 if count is null or omitted - return if (count != null) 8 else 0 -} - -/** - * Calculates the size of a MapEntry in bytes. - * Spec: OME3 - */ -private fun ObjectsMapEntry.size(): Int { - // The size is equal to the size of the data property, calculated per "OD3" - return data?.size() ?: 0 -} - -/** - * Calculates the size of an ObjectData in bytes. - * Spec: OD3 - */ -private fun ObjectData.size(): Int { - string?.let { return it.byteSize } // Spec: OD3e - number?.let { return 8 } // Spec: OD3d - boolean?.let { return 1 } // Spec: OD3b - bytes?.let { return Base64.getDecoder().decode(it).size } // Spec: OD3c - json?.let { return it.toString().byteSize } // Spec: OD3e - return 0 -} - -internal fun ObjectData?.isInvalid(): Boolean { - return this?.objectId.isNullOrEmpty() && - this?.string == null && - this?.number == null && - this?.boolean == null && - this?.bytes == null && - this?.json == null -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt deleted file mode 100644 index 9c669f033..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt +++ /dev/null @@ -1,328 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.ObjectUpdate -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.types.AblyException -import io.ably.lib.util.Log -import kotlinx.coroutines.CompletableDeferred - -/** - * @spec RTO5 - Processes OBJECT and OBJECT_SYNC messages during sync sequences - * @spec RTO6 - Creates zero-value objects when needed - */ -internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObjects): ObjectsStateCoordinator() { - private val tag = "ObjectsManager" - /** - * @spec RTO5 - Sync objects pool for collecting sync messages - */ - private val syncObjectsPool = mutableMapOf() - private var currentSyncId: String? = null - /** - * @spec RTO7 - Buffered object operations during sync - */ - private val bufferedObjectOperations = mutableListOf() // RTO7a - private var syncCompletionWaiter: CompletableDeferred? = null - - /** - * Handles object messages (non-sync messages). - * - * @spec RTO8 - Buffers messages if not synced, applies immediately if synced - */ - internal fun handleObjectMessages(objectMessages: List) { - if (realtimeObjects.state != ObjectsState.Synced) { - // RTO7 - The client receives object messages in realtime over the channel concurrently with the sync sequence. - // Some of the incoming object messages may have already been applied to the objects described in - // the sync sequence, but others may not; therefore we must buffer these messages so that we can apply - // them to the objects once the sync is complete. - Log.v(tag, "Buffering ${objectMessages.size} object messages, state: ${realtimeObjects.state}") - bufferedObjectOperations.addAll(objectMessages) // RTO8a - return - } - - // Apply messages immediately if synced - applyObjectMessages(objectMessages, ObjectsOperationSource.CHANNEL) // RTO8b - } - - /** - * Handles object sync messages. - * - * @spec RTO5 - Parses sync channel serial and manages sync sequences - */ - internal fun handleObjectSyncMessages(objectMessages: List, syncChannelSerial: String?) { - val syncTracker = ObjectsSyncTracker(syncChannelSerial) - val isNewSync = syncTracker.hasSyncStarted(currentSyncId) - if (isNewSync) { - // RTO5a2 - new sync sequence started - startNewSync(syncTracker.syncId) - } - - // RTO5a3 - continue current sync sequence - applyObjectSyncMessages(objectMessages) // RTO5f - - // RTO5a4 - if this is the last (or only) message in a sequence of sync updates, end the sync - if (syncTracker.hasSyncEnded()) { - // defer the state change event until the next tick if this was a new sync sequence - // to allow any event listeners to process the start of the new sequence event that was emitted earlier during this event loop. - endSync() - } - } - - /** - * Starts a new sync sequence. - * - * @spec RTO5 - Sync sequence initialization - */ - internal fun startNewSync(syncId: String?) { - Log.v(tag, "Starting new sync sequence: syncId=$syncId") - - syncObjectsPool.clear() // RTO5a2a - currentSyncId = syncId - syncCompletionWaiter = CompletableDeferred() - stateChange(ObjectsState.Syncing) - } - - /** - * Ends the current sync sequence. - * - * @spec RTO5c - Applies sync data and buffered operations - */ - internal fun endSync() { - Log.v(tag, "Ending sync sequence") - applySync() // RTO5c1/2/7 - applyObjectMessages(bufferedObjectOperations, ObjectsOperationSource.CHANNEL) // RTO5c6 - bufferedObjectOperations.clear() // RTO5c5 - syncObjectsPool.clear() // RTO5c4 - currentSyncId = null // RTO5c3 - realtimeObjects.appliedOnAckSerials.clear() // RTO5c9 - stateChange(ObjectsState.Synced) // RTO5c8 - syncCompletionWaiter?.complete(Unit) - syncCompletionWaiter = null - } - - /** - * Called from publishAndApply (via withContext sequentialScope). - * If SYNCED: apply immediately with LOCAL source. - * If not SYNCED: suspend until endSync transitions to SYNCED (RTO20e), then apply. - */ - internal suspend fun applyAckResult(messages: List) { - if (realtimeObjects.state != ObjectsState.Synced) { - if (syncCompletionWaiter == null) syncCompletionWaiter = CompletableDeferred() - syncCompletionWaiter?.await() // suspends; resumes after endSync transitions to SYNCED (RTO20e1) - } - applyObjectMessages(messages, ObjectsOperationSource.LOCAL) // RTO20f - } - - /** - * Fails all pending apply waiters. - * Called when the channel enters DETACHED/SUSPENDED/FAILED (RTO20e1). - */ - internal fun failBufferedAcks(error: AblyException) { - syncCompletionWaiter?.completeExceptionally(error) - syncCompletionWaiter = null - } - - /** - * Clears the sync objects pool. - * Used by DefaultRealtimeObjects.handleStateChange. - */ - internal fun clearSyncObjectsPool() { - syncObjectsPool.clear() - } - - /** - * Clears the buffered object operations. - * Used by DefaultRealtimeObjects.handleStateChange. - */ - internal fun clearBufferedObjectOperations() { - bufferedObjectOperations.clear() - } - - /** - * Applies sync data to objects pool. - * - * @spec RTO5c - Processes sync data and updates objects pool - */ - private fun applySync() { - if (syncObjectsPool.isEmpty()) { - return - } - - val receivedObjectIds = mutableSetOf() - // RTO5c1a2 - List to collect updates for existing objects - val existingObjectUpdates = mutableListOf>() - - // RTO5c1 - for ((objectId, objectMessage) in syncObjectsPool) { - val objectState = objectMessage.objectState as ObjectState // we have non-null objectState here due to RTO5f - receivedObjectIds.add(objectId) - val existingObject = realtimeObjects.objectsPool.get(objectId) - - // RTO5c1a - if (existingObject != null) { - // Update existing object - val update = existingObject.applyObjectSync(objectMessage) // RTO5c1a1 - existingObjectUpdates.add(Pair(existingObject, update)) - } else { // RTO5c1b - // RTO5c1b1, RTO5c1b1a, RTO5c1b1b - Create new object and add it to the pool - val newObject = createObjectFromState(objectState) ?: continue // RTO5c1b1c - skip unsupported - newObject.applyObjectSync(objectMessage) - realtimeObjects.objectsPool.set(objectId, newObject) - } - } - - // RTO5c2 - need to remove realtimeObject instances from the ObjectsPool for which objectIds were not received during the sync sequence - realtimeObjects.objectsPool.deleteExtraObjectIds(receivedObjectIds) - - // RTO5c7 - call subscription callbacks for all updated existing objects - existingObjectUpdates.forEach { (obj, update) -> - obj.notifyUpdated(update) - } - } - - /** - * Applies object messages to objects. - * - * @spec RTO9 - Creates zero-value objects if they don't exist - */ - private fun applyObjectMessages( - objectMessages: List, - source: ObjectsOperationSource = ObjectsOperationSource.CHANNEL, - ) { - // RTO9a - for (objectMessage in objectMessages) { - if (objectMessage.operation == null) { - // RTO9a1 - Log.w(tag, "Object message received without operation field, skipping message: ${objectMessage.id}") - continue - } - - val objectOperation: ObjectOperation = objectMessage.operation // RTO9a2 - if (objectOperation.action == ObjectOperationAction.Unknown) { - // RTO9a2b - object operation action is unknown, skip the message - Log.w(tag, "Object operation action is unknown, skipping message: ${objectMessage.id}") - continue - } - - // RTO9a3 - skip operations already applied on ACK (discard without taking any further action). - // This check comes before zero-value object creation (RTO9a2a1) so that no zero-value object is - // created for an objectId not yet in the pool when the echo is being discarded. - // Note: siteTimeserials is NOT updated here intentionally — updating it to the echo's serial would - // incorrectly reject older-but-unprocessed operations from the same site that arrive after the echo. - if (objectMessage.serial != null && - realtimeObjects.appliedOnAckSerials.contains(objectMessage.serial)) { - Log.d(tag, "RTO9a3: serial ${objectMessage.serial} already applied on ACK; discarding echo") - realtimeObjects.appliedOnAckSerials.remove(objectMessage.serial) - continue // discard without taking any further action - } - - // RTO9a2a - we can receive an op for an object id we don't have yet in the pool. instead of buffering such operations, - // we can create a zero-value object for the provided object id and apply the operation to that zero-value object. - // this also means that all objects are capable of applying the corresponding *_CREATE ops on themselves, - // since they need to be able to eventually initialize themselves from that *_CREATE op. - // so to simplify operations handling, we always try to create a zero-value object in the pool first, - // and then we can always apply the operation on the existing object in the pool. - val obj = realtimeObjects.objectsPool.createZeroValueObjectIfNotExists(objectOperation.objectId) // RTO9a2a1 - val applied = obj.applyObject(objectMessage, source) // RTO9a2a2, RTO9a2a3 - if (source == ObjectsOperationSource.LOCAL && applied && objectMessage.serial != null) { - realtimeObjects.appliedOnAckSerials.add(objectMessage.serial) // RTO9a2a4 - } - } - } - - /** - * Applies sync messages to sync data pool, merging partial sync messages for the same objectId. - * - * @spec RTO5f - Collects and merges object states during sync sequence - */ - private fun applyObjectSyncMessages(objectMessages: List) { - for (objectMessage in objectMessages) { - if (objectMessage.objectState == null) { - Log.w(tag, "Object message received during OBJECT_SYNC without object field, skipping message: ${objectMessage.id}") - continue - } - - val objectState: ObjectState = objectMessage.objectState - val objectId = objectState.objectId - val existingEntry = syncObjectsPool[objectId] - - if (existingEntry == null) { - // RTO5f1 - objectId not in pool, store directly - if (objectState.counter != null || objectState.map != null) { - syncObjectsPool[objectId] = objectMessage - } else { - // RTO5c1b1c - object state must contain either counter or map data - Log.w(tag, "Object state received without counter or map data, skipping message: ${objectMessage.id}") - } - continue - } - - // RTO5f2 - objectId already in pool; this is a partial sync message, merge based on type - when { - objectState.map != null -> { - // RTO5f2a - map object: merge entries - if (objectState.tombstone) { - // RTO5f2a1 - tombstone: replace pool entry entirely - syncObjectsPool[objectId] = objectMessage - } else { - // RTO5f2a2 - merge map entries; server guarantees no duplicate keys across partials - val existingState = existingEntry.objectState!! // non-null for existing entry - val mergedEntries = existingState.map?.entries.orEmpty() + objectState.map.entries.orEmpty() - val mergedMap = (existingState.map ?: ObjectsMap()).copy(entries = mergedEntries) - val mergedState = existingState.copy(map = mergedMap) - syncObjectsPool[objectId] = existingEntry.copy(objectState = mergedState) - } - } - objectState.counter != null -> { - // RTO5f2b - counter objects must never be split across messages - Log.e(tag, "Received partial sync message for a counter object, skipping: ${objectMessage.id}") - } - else -> { - // RTO5f2c - unsupported type, log warning and skip - Log.w(tag, "Received partial sync message for an unsupported object type, skipping: ${objectMessage.id}") - } - } - } - } - - /** - * Creates an object from object state. - * - * @spec RTO5c1b - Creates objects from object state based on type - */ - private fun createObjectFromState(objectState: ObjectState): BaseRealtimeObject? { - return when { - objectState.counter != null -> DefaultLiveCounter.zeroValue(objectState.objectId, realtimeObjects) // RTO5c1b1a - objectState.map != null -> DefaultLiveMap.zeroValue(objectState.objectId, realtimeObjects) // RTO5c1b1b - else -> { - // RTO5c1b1c - unsupported object type, skip gracefully - Log.w(tag, "Received unsupported object state during OBJECT_SYNC (no counter or map), skipping objectId: ${objectState.objectId}") - null - } - } - } - - /** - * Changes the state and emits events. - * - * @spec RTO2 - Emits state change events for syncing and synced states - */ - private fun stateChange(newState: ObjectsState) { - if (realtimeObjects.state == newState) { - return - } - Log.v(tag, "Objects state changed to: $newState from ${realtimeObjects.state}") - realtimeObjects.state = newState - - // deferEvent not needed since objectsStateChanged processes events in a sequential coroutine scope - objectsStateChanged(newState) - } - - internal fun dispose() { - syncCompletionWaiter?.cancel() - syncObjectsPool.clear() - bufferedObjectOperations.clear() - disposeObjectsStateListeners() - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsOperationSource.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsOperationSource.kt deleted file mode 100644 index e850d31b8..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsOperationSource.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.ably.lib.objects - -/** @spec RTO22 */ -internal enum class ObjectsOperationSource { - LOCAL, // RTO22a - applied upon receipt of ACK - CHANNEL // RTO22b - received over a Realtime channel -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt deleted file mode 100644 index 224cd606f..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt +++ /dev/null @@ -1,172 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.util.Log -import kotlinx.coroutines.* -import java.util.concurrent.ConcurrentHashMap - -/** - * Constants for ObjectsPool configuration - */ -internal object ObjectsPoolDefaults { - const val GC_INTERVAL_MS = 1000L * 60 * 5 // 5 minutes - /** - * The SDK will attempt to use the `objectsGCGracePeriod` value provided by the server in the `connectionDetails` - * object of the `CONNECTED` event. - * If the server does not provide this value, the SDK will fall back to this default value. - * Must be > 2 minutes to ensure we keep tombstones long enough to avoid the possibility of receiving an operation - * with an earlier serial that would not have been applied if the tombstone still existed. - * - * Applies both for map entries tombstones and object tombstones. - */ - const val GC_GRACE_PERIOD_MS = 1000L * 60 * 60 * 24 // 24 hours -} - -/** - * Root object ID constant - */ -internal const val ROOT_OBJECT_ID = "root" - -/** - * ObjectsPool manages a pool of objects for a channel. - * - * @spec RTO3 - Maintains an objects pool for all objects on the channel - */ -internal class ObjectsPool( - private val realtimeObjects: DefaultRealtimeObjects -) { - private val tag = "ObjectsPool" - - /** - * ConcurrentHashMap for thread-safe access from public APIs in LiveMap and LiveCounter. - * @spec RTO3a - Pool storing all ably objects by object ID - */ - private val pool = ConcurrentHashMap() - - /** - * Coroutine scope for garbage collection - */ - private val gcScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - private var gcJob: Job // Job for the garbage collection coroutine - - @Volatile private var gcGracePeriod = ObjectsPoolDefaults.GC_GRACE_PERIOD_MS - private var gcPeriodSubscription: ObjectsSubscription - - init { - // RTO3b - Initialize pool with root object - pool[ROOT_OBJECT_ID] = DefaultLiveMap.zeroValue(ROOT_OBJECT_ID, realtimeObjects) - // Start garbage collection coroutine with server-provided grace period if available - gcPeriodSubscription = realtimeObjects.adapter.onGCGracePeriodUpdated { period -> - period?.let { - gcGracePeriod = it - Log.i(tag, "Using objectsGCGracePeriod from server: $gcGracePeriod ms") - } ?: Log.i(tag, "Server did not provide objectsGCGracePeriod, using default: $gcGracePeriod ms") - } - gcJob = startGCJob() - } - - /** - * Gets an object from the pool by object ID. - */ - internal fun get(objectId: String): BaseRealtimeObject? { - return pool[objectId] - } - - /** - * Sets a realtime object in the pool. - */ - internal fun set(objectId: String, realtimeObject: BaseRealtimeObject) { - pool[objectId] = realtimeObject - } - - /** - * Removes all objects but root from the pool and clears the data for root. - * Does not create a new root object, so the reference to the root object remains the same. - */ - internal fun resetToInitialPool(emitUpdateEvents: Boolean) { - pool.entries.removeIf { (key, _) -> key != ROOT_OBJECT_ID } // only keep the root object - clearObjectsData(emitUpdateEvents) // RTO4b2a - clear the root object and emit update events - } - - - /** - * Deletes objects from the pool for which object ids are not found in the provided array of ids. - * Spec: RTO5c2 - */ - internal fun deleteExtraObjectIds(objectIds: MutableSet) { - pool.entries.removeIf { (key, _) -> key !in objectIds && key != ROOT_OBJECT_ID } // RTO5c2a - Keep root object - } - - /** - * Clears the data stored for all objects in the pool. - */ - internal fun clearObjectsData(emitUpdateEvents: Boolean) { - for (obj in pool.values) { - val update = obj.clearData() - if (emitUpdateEvents) obj.notifyUpdated(update) - } - } - - /** - * Creates a zero-value object if it doesn't exist in the pool. - * - * @spec RTO6 - Creates zero-value objects when needed - */ - internal fun createZeroValueObjectIfNotExists(objectId: String): BaseRealtimeObject { - val existingObject = get(objectId) - if (existingObject != null) { - return existingObject // RTO6a - } - - val parsedObjectId = ObjectId.fromString(objectId) // RTO6b - return when (parsedObjectId.type) { - ObjectType.Map -> DefaultLiveMap.zeroValue(objectId, realtimeObjects) // RTO6b2 - ObjectType.Counter -> DefaultLiveCounter.zeroValue(objectId, realtimeObjects) // RTO6b3 - }.apply { - set(objectId, this) // RTO6b4 - Add the zero-value object to the pool - } - } - - /** - * Garbage collection interval handler. - */ - private fun onGCInterval() { - pool.entries.removeIf { (_, obj) -> - if (obj.isEligibleForGc(gcGracePeriod)) { true } // Remove from pool - else { - obj.onGCInterval(gcGracePeriod) - false // Keep in pool - } - } - } - - /** - * Starts the garbage collection coroutine. - */ - private fun startGCJob() : Job { - return gcScope.launch { - while (isActive) { - try { - onGCInterval() - } catch (e: Exception) { - Log.e(tag, "Error during garbage collection", e) - } - delay(ObjectsPoolDefaults.GC_INTERVAL_MS) - } - } - } - - /** - * Disposes of the ObjectsPool, cleaning up resources. - * Should be called when the pool is no longer needed. - */ - fun dispose() { - gcPeriodSubscription.unsubscribe() - gcJob.cancel() - gcScope.cancel() - pool.clear() - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt deleted file mode 100644 index cdd742ec0..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt +++ /dev/null @@ -1,108 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.objects.state.ObjectsStateChange -import io.ably.lib.objects.state.ObjectsStateEvent -import io.ably.lib.util.EventEmitter -import io.ably.lib.util.Log -import kotlinx.coroutines.* - -/** - * @spec RTO2 - enum representing objects state - */ -internal enum class ObjectsState { - Initialized, - Syncing, - Synced -} - -/** - * Maps internal ObjectsState values to their corresponding public ObjectsStateEvent values. - * Used to determine which events should be emitted when state changes occur. - * INITIALIZED maps to null (no event), while SYNCING and SYNCED map to their respective events. - */ -private val objectsStateToEventMap = mapOf( - ObjectsState.Initialized to null, - ObjectsState.Syncing to ObjectsStateEvent.SYNCING, - ObjectsState.Synced to ObjectsStateEvent.SYNCED -) - -/** - * An interface for managing and communicating changes in the synchronization state of objects. - * - * Implementations should ensure thread-safe event emission and proper synchronization - * between state change notifications. - */ -internal interface HandlesObjectsStateChange { - /** - * Handles changes in the state of objects by notifying all registered listeners. - * Implementations should ensure thread-safe event emission to both internal and public listeners. - * Makes sure every event is processed in the order they were received. - * @param newState The new state of the objects, SYNCING or SYNCED. - */ - fun objectsStateChanged(newState: ObjectsState) - - /** - * Suspends the current coroutine until objects are synchronized. - * Returns immediately if state is already SYNCED, otherwise waits for the SYNCED event. - * - * @param currentState The current state of objects to determine if waiting is necessary - */ - suspend fun ensureSynced(currentState: ObjectsState) - - /** - * Disposes all registered state change listeners and cancels any pending operations. - * Should be called when the associated RealtimeObjects instance is no longer needed. - */ - fun disposeObjectsStateListeners() -} - - -internal abstract class ObjectsStateCoordinator : ObjectsStateChange, HandlesObjectsStateChange { - private val tag = "ObjectsStateCoordinator" - private val internalObjectStateEmitter = ObjectsStateEmitter() - // related to RTC10, should have a separate EventEmitter for users of the library - private val externalObjectStateEmitter = ObjectsStateEmitter() - - override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription { - externalObjectStateEmitter.on(event, listener) - return ObjectsSubscription { - externalObjectStateEmitter.off(event, listener) - } - } - - override fun off(listener: ObjectsStateChange.Listener) = externalObjectStateEmitter.off(listener) - - override fun offAll() = externalObjectStateEmitter.off() - - override fun objectsStateChanged(newState: ObjectsState) { - objectsStateToEventMap[newState]?.let { objectsStateEvent -> - internalObjectStateEmitter.emit(objectsStateEvent) - externalObjectStateEmitter.emit(objectsStateEvent) - } - } - - override suspend fun ensureSynced(currentState: ObjectsState) { - if (currentState != ObjectsState.Synced) { - val deferred = CompletableDeferred() - internalObjectStateEmitter.once(ObjectsStateEvent.SYNCED) { - Log.v(tag, "Objects state changed to SYNCED, resuming ensureSynced") - deferred.complete(Unit) - } - deferred.await() - } - } - - override fun disposeObjectsStateListeners() = offAll() -} - -private class ObjectsStateEmitter : EventEmitter() { - private val tag = "ObjectsStateEmitter" - override fun apply(listener: ObjectsStateChange.Listener?, event: ObjectsStateEvent?, vararg args: Any?) { - try { - event?.let { listener?.onStateChanged(it) } - ?: Log.w(tag, "Null event passed to ObjectsStateChange Listener callback") - } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing listener callback for event: $event", t) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt deleted file mode 100644 index 5c2a193d5..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt +++ /dev/null @@ -1,63 +0,0 @@ -package io.ably.lib.objects - -/** - * @spec RTO5 - SyncTracker class for tracking objects sync status - */ -internal class ObjectsSyncTracker(syncChannelSerial: String?) { - private val syncSerial: String? = syncChannelSerial - internal val syncId: String? - internal val syncCursor: String? - - init { - val parsed = parseSyncChannelSerial(syncChannelSerial) - syncId = parsed.first - syncCursor = parsed.second - } - - /** - * Checks if a new sync sequence has started. - * - * @param prevSyncId The previously stored sync ID - * @return true if a new sync sequence has started, false otherwise - * - * Spec: RTO5a5, RTO5a2 - */ - internal fun hasSyncStarted(prevSyncId: String?): Boolean { - return syncSerial.isNullOrEmpty() || prevSyncId != syncId - } - - /** - * Checks if the current sync sequence has ended. - * - * @return true if the sync sequence has ended, false otherwise - * - * Spec: RTO5a5, RTO5a4 - */ - internal fun hasSyncEnded(): Boolean { - return syncSerial.isNullOrEmpty() || syncCursor.isNullOrEmpty() - } - - companion object { - /** - * Parses sync channel serial to extract syncId and syncCursor. - * - * @param syncChannelSerial The sync channel serial to parse - * @return Pair of syncId and syncCursor, both null if parsing fails - */ - private fun parseSyncChannelSerial(syncChannelSerial: String?): Pair { - if (syncChannelSerial.isNullOrEmpty()) { - return Pair(null, null) - } - - // RTO5a1 - syncChannelSerial is a two-part identifier: : - val match = Regex("^([\\w-]+):(.*)$").find(syncChannelSerial) - return if (match != null) { - val syncId = match.groupValues[1] - val syncCursor = match.groupValues[2] - Pair(syncId, syncCursor) - } else { - Pair(null, null) - } - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt deleted file mode 100644 index 09b8b1c14..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt +++ /dev/null @@ -1,37 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.types.AblyException -import io.ably.lib.util.SystemClock -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlin.concurrent.Volatile - -/** - * ServerTime is a utility object that provides the current server time - * Spec: RTO16 - */ -internal object ServerTime { - @Volatile - private var serverTimeOffset: Long? = null - private val mutex = Mutex() - - /** - * Spec: RTO16a - */ - @Throws(AblyException::class) - internal suspend fun getCurrentTime(adapter: ObjectsAdapter): Long { - val clock = SystemClock.clockFrom(adapter.clientOptions) - if (serverTimeOffset == null) { - mutex.withLock { - if (serverTimeOffset == null) { // Double-checked locking to ensure thread safety - val serverTime: Long = withContext(Dispatchers.IO) { adapter.time } - serverTimeOffset = serverTime - clock.currentTimeMillis() - return serverTime - } - } - } - return clock.currentTimeMillis() + serverTimeOffset!! - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/Utils.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/Utils.kt deleted file mode 100644 index 3e136163e..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/Utils.kt +++ /dev/null @@ -1,117 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.types.AblyException -import io.ably.lib.types.ErrorInfo -import io.ably.lib.util.Log -import kotlinx.coroutines.* -import java.nio.charset.StandardCharsets -import java.util.concurrent.CancellationException - -internal fun ablyException( - errorMessage: String, - errorCode: ErrorCode, - statusCode: HttpStatusCode = HttpStatusCode.BadRequest, - cause: Throwable? = null, -): AblyException { - val errorInfo = createErrorInfo(errorMessage, errorCode, statusCode) - return createAblyException(errorInfo, cause) -} - -internal fun ablyException( - errorInfo: ErrorInfo, - cause: Throwable? = null, -): AblyException = createAblyException(errorInfo, cause) - -private fun createErrorInfo( - errorMessage: String, - errorCode: ErrorCode, - statusCode: HttpStatusCode, -) = ErrorInfo(errorMessage, statusCode.code, errorCode.code) - -private fun createAblyException( - errorInfo: ErrorInfo, - cause: Throwable?, -) = cause?.let { AblyException.fromErrorInfo(it, errorInfo) } - ?: AblyException.fromErrorInfo(errorInfo) - -internal fun clientError(errorMessage: String) = ablyException(errorMessage, ErrorCode.BadRequest, HttpStatusCode.BadRequest) - -internal fun serverError(errorMessage: String) = ablyException(errorMessage, ErrorCode.InternalError, HttpStatusCode.InternalServerError) - -internal fun objectError(errorMessage: String, cause: Throwable? = null): AblyException { - return ablyException(errorMessage, ErrorCode.InvalidObject, HttpStatusCode.InternalServerError, cause) -} - -internal fun invalidInputError(errorMessage: String, cause: Throwable? = null): AblyException { - return ablyException(errorMessage, ErrorCode.InvalidInputParams, HttpStatusCode.InternalServerError, cause) -} - -/** - * Calculates the byte size of a string. - * For non-ASCII, the byte size can be 2–4x the character count. For ASCII, there is no difference. - * e.g. "Hello" has a byte size of 5, while "你" has a byte size of 3 and "😊" has a byte size of 4. - */ -internal val String.byteSize: Int - get() = this.toByteArray(StandardCharsets.UTF_8).size - -/** - * A channel-specific coroutine scope for executing callbacks asynchronously in the RealtimeObjects system. - * Provides safe execution of suspend functions with results delivered via callbacks. - * Supports proper error handling and cancellation during DefaultRealtimeObjects disposal. - */ -internal class ObjectsAsyncScope(channelName: String) { - private val tag = "ObjectsCallbackScope-$channelName" - - private val scope = - CoroutineScope(Dispatchers.Default + CoroutineName(tag) + SupervisorJob()) - - internal fun launchWithCallback(callback: ObjectsCallback, block: suspend () -> T) { - scope.launch { - try { - val result = block() - try { callback.onSuccess(result) } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing callback's onSuccess handler", t) - } // catch and don't rethrow error from callback - } catch (throwable: Throwable) { - when (throwable) { - is AblyException -> { callback.onError(throwable) } - else -> { - val ex = ablyException("Error executing operation", ErrorCode.BadRequest, cause = throwable) - callback.onError(ex) - } - } - } - } - } - - internal fun launchWithVoidCallback(callback: ObjectsCallback, block: suspend () -> Unit) { - scope.launch { - try { - block() - try { callback.onSuccess(null) } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing callback's onSuccess handler", t) - } // catch and don't rethrow error from callback - } catch (throwable: Throwable) { - when (throwable) { - is AblyException -> { callback.onError(throwable) } - else -> { - val ex = ablyException("Error executing operation", ErrorCode.BadRequest, cause = throwable) - callback.onError(ex) - } - } - } - } - } - - internal fun cancel(cause: CancellationException) { - scope.coroutineContext.cancelChildren(cause) - } -} - -/** - * Generates a random nonce string for object creation. - */ -internal fun generateNonce(): String { - val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" // avoid calculation using range - return (1..16).map { chars.random() }.joinToString("") -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt deleted file mode 100644 index 934789789..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt +++ /dev/null @@ -1,231 +0,0 @@ -package io.ably.lib.objects.type - -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.ObjectsOperationSource -import io.ably.lib.objects.objectError -import io.ably.lib.objects.type.livecounter.noOpCounterUpdate -import io.ably.lib.objects.type.livemap.noOpMapUpdate -import io.ably.lib.util.Clock -import io.ably.lib.util.Log -import io.ably.lib.util.SystemClock - -internal enum class ObjectType(val value: String) { - Map("map"), - Counter("counter") -} - -// Spec: RTLO4b4b -internal val ObjectUpdate.noOp get() = this.update == null - -/** - * Provides common functionality and base implementation for LiveMap and LiveCounter. - * - * @spec RTLO1/RTLO2 - Base class for LiveMap/LiveCounter object - * - * This should also be included in logging - */ -internal abstract class BaseRealtimeObject( - internal val objectId: String, // // RTLO3a - internal val objectType: ObjectType, - internal val clock: Clock = SystemClock.INSTANCE, -) : ObjectLifecycleCoordinator() { - - protected open val tag = "BaseRealtimeObject" - - internal val siteTimeserials = mutableMapOf() // RTLO3b - - internal var createOperationIsMerged = false // RTLO3c - - @Volatile - internal var isTombstoned = false // Accessed from public API for LiveMap/LiveCounter - - private var tombstonedAt: Long? = null - - /** - * This is invoked by ObjectMessage having updated data with parent `ProtocolMessageAction` as `object_sync` - * @return an update describing the changes - * - * @spec RTLM6/RTLC6 - Overrides ObjectMessage with object data state from sync to LiveMap/LiveCounter - */ - internal fun applyObjectSync(objectMessage: ObjectMessage): ObjectUpdate { - val objectState = objectMessage.objectState as ObjectState // we have non-null objectState here due to RTO5f - validate(objectState) - // object's site serials are still updated even if it is tombstoned, so always use the site serials received from the operation. - // should default to empty map if site serials do not exist on the object state, so that any future operation may be applied to this object. - siteTimeserials.clear() - siteTimeserials.putAll(objectState.siteTimeserials) // RTLC6a, RTLM6a - - if (isTombstoned) { - // this object is tombstoned. this is a terminal state which can't be overridden. skip the rest of object state message processing - if (objectType == ObjectType.Map) { - return noOpMapUpdate - } - return noOpCounterUpdate - } - return applyObjectState(objectState, objectMessage) // RTLM6, RTLC6 - } - - /** - * This is invoked by ObjectMessage having updated data with parent `ProtocolMessageAction` as `object` - * @return true if the operation was meaningfully applied, false otherwise - * - * @spec RTLM15/RTLC7 - Applies ObjectMessage with object data operations to LiveMap/LiveCounter - */ - internal fun applyObject(objectMessage: ObjectMessage, source: ObjectsOperationSource): Boolean { - validateObjectId(objectMessage.operation?.objectId) - - val msgTimeSerial = objectMessage.serial - val msgSiteCode = objectMessage.siteCode - val objectOperation = objectMessage.operation as ObjectOperation - - if (!canApplyOperation(msgSiteCode, msgTimeSerial)) { - // RTLC7b, RTLM15b - Log.v( - tag, - "Skipping ${objectOperation.action} op: op serial $msgTimeSerial <= site serial ${siteTimeserials[msgSiteCode]}; " + - "objectId=$objectId" - ) - return false // RTLC7b / RTLM15b - } - // RTLC7c / RTLM15c - only update siteTimeserials for CHANNEL source - if (source == ObjectsOperationSource.CHANNEL) { - siteTimeserials[msgSiteCode!!] = msgTimeSerial!! // RTLC7c, RTLM15c - } - - if (isTombstoned) { - // this object is tombstoned so the operation cannot be applied - return false // RTLC7e / RTLM15e - } - return applyObjectOperation(objectOperation, objectMessage) // RTLC7d - } - - /** - * Checks if an operation can be applied based on serial comparison. - * - * @spec RTLO4a - Serial comparison logic for LiveMap/LiveCounter operations - */ - internal fun canApplyOperation(siteCode: String?, timeSerial: String?): Boolean { - if (timeSerial.isNullOrEmpty()) { - throw objectError("Invalid serial: $timeSerial") // RTLO4a3 - } - if (siteCode.isNullOrEmpty()) { - throw objectError("Invalid site code: $siteCode") // RTLO4a3 - } - val existingSiteSerial = siteTimeserials[siteCode] // RTLO4a4 - return existingSiteSerial == null || timeSerial > existingSiteSerial // RTLO4a5, RTLO4a6 - } - - internal fun validateObjectId(objectId: String?) { - if (this.objectId != objectId) { - throw objectError("Invalid object: incoming objectId=$objectId; $objectType objectId=${this.objectId}") - } - } - - /** - * Marks the object as tombstoned. - */ - internal fun tombstone(serialTimestamp: Long?): ObjectUpdate { - if (serialTimestamp == null) { - Log.w(tag, "Tombstoning object $objectId without serial timestamp, using local timestamp instead") - } - isTombstoned = true - tombstonedAt = serialTimestamp?: clock.currentTimeMillis() - val update = clearData() - // Emit object lifecycle event for deletion - objectLifecycleChanged(ObjectLifecycle.Deleted) - return update - } - - /** - * Checks if the object is eligible for garbage collection. - * - * An object is eligible for garbage collection if it has been tombstoned and - * the time since tombstoning exceeds the specified grace period. - * - * @param gcGracePeriod The grace period in milliseconds that tombstoned objects - * should be kept before being eligible for collection. - * This value is retrieved from the server's connection details - * or defaults to 24 hours if not provided by the server. - * @return true if the object is tombstoned and the grace period has elapsed, - * false otherwise - */ - internal fun isEligibleForGc(gcGracePeriod: Long): Boolean { - val currentTime = clock.currentTimeMillis() - return isTombstoned && tombstonedAt?.let { currentTime - it >= gcGracePeriod } == true - } - - /** - * Validates that the provided object state is compatible with this object. - * Checks object ID, type-specific validations, and any included create operations. - */ - abstract fun validate(state: ObjectState) - - /** - * Applies an object state received during synchronization to this object. - * This method should update the internal data structure with the complete state - * received from the server. - * - * @param objectState The complete state to apply to this object - * @return A map describing the changes made to the object's data - * - */ - abstract fun applyObjectState(objectState: ObjectState, message: ObjectMessage): ObjectUpdate - - /** - * Applies an operation to this object. - * This method handles the specific operation actions (e.g., update, remove) - * by modifying the underlying data structure accordingly. - * - * @param operation The operation containing the action and data to apply - * @param message The complete object message containing the operation - * @return true if the operation was meaningfully applied, false otherwise - * - */ - abstract fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage): Boolean - - /** - * Clears the object's data and returns an update describing the changes. - * This is called during tombstoning and explicit clear operations. - * - * This method: - * 1. Calculates a diff between the current state and an empty state - * 2. Clears all entries from the underlying data structure - * 3. Returns a map containing metadata about what was cleared - * - * The returned map is used to notifying other components about what entries were removed. - * - * @return A map representing the diff of changes made - */ - abstract fun clearData(): ObjectUpdate - - /** - * Notifies subscribers about changes made to this object. Propagates updates through the - * appropriate manager after converting the generic update map to type-specific update objects. - * Spec: RTLO4b4c - */ - abstract fun notifyUpdated(update: ObjectUpdate) - - /** - * Called during garbage collection intervals to clean up expired entries. - * - * This method is invoked periodically (every 5 minutes) by the ObjectsPool - * to perform cleanup of tombstoned data that has exceeded the grace period. - * - * This method should identify and remove entries that: - * - Have been marked as tombstoned - * - Have a tombstone timestamp older than the specified grace period - * - * @param gcGracePeriod The grace period in milliseconds that tombstoned entries - * should be kept before being eligible for removal. - * This value is retrieved from the server's connection details - * or defaults to 24 hours if not provided by the server. - * Must be greater than 2 minutes to ensure proper operation - * ordering and avoid issues with delayed operations. - * - * Implementations typically use single-pass removal techniques to - * efficiently clean up expired data without creating temporary collections. - */ - abstract fun onGCInterval(gcGracePeriod: Long) -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/ObjectLifecycle.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/ObjectLifecycle.kt deleted file mode 100644 index 70abdea85..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/ObjectLifecycle.kt +++ /dev/null @@ -1,84 +0,0 @@ -package io.ably.lib.objects.type - -import io.ably.lib.objects.ObjectsSubscription -import io.ably.lib.util.EventEmitter -import io.ably.lib.util.Log - -/** - * Internal enum representing object lifecycle states - */ -internal enum class ObjectLifecycle { - Created, - Active, - Deleted -} - -/** - * Maps internal ObjectLifecycle values to their corresponding public ObjectLifecycleEvent values. - * Used to determine which events should be emitted when lifecycle changes occur. - * CREATED and ACTIVE map to null (no public event), while DELETED maps to the public DELETED event. - */ -private val objectLifecycleToEventMap = mapOf( - ObjectLifecycle.Created to null, - ObjectLifecycle.Active to null, - ObjectLifecycle.Deleted to ObjectLifecycleEvent.DELETED -) - -/** - * An interface for managing and communicating changes in the lifecycle state of objects. - * - * Implementations should ensure thread-safe event emission and proper lifecycle - * event notifications. - */ -internal interface HandlesObjectLifecycleChange { - /** - * Handles changes in the lifecycle of objects by notifying all registered listeners. - * Implementations should ensure thread-safe event emission to both internal and public listeners. - * Makes sure every event is processed in the order they were received. - * @param newLifecycle The new lifecycle state of the object. - */ - fun objectLifecycleChanged(newLifecycle: ObjectLifecycle) - - /** - * Disposes all registered lifecycle change listeners and cancels any pending operations. - * Should be called when the associated object is no longer needed. - */ - fun disposeObjectLifecycleListeners() -} - -internal abstract class ObjectLifecycleCoordinator : ObjectLifecycleChange, HandlesObjectLifecycleChange { - private val tag = "ObjectLifecycleCoordinator" - // EventEmitter for users of the library - private val objectLifecycleEmitter = ObjectLifecycleEmitter() - - override fun on(event: ObjectLifecycleEvent, listener: ObjectLifecycleChange.Listener): ObjectsSubscription { - objectLifecycleEmitter.on(event, listener) - return ObjectsSubscription { - objectLifecycleEmitter.off(event, listener) - } - } - - override fun off(listener: ObjectLifecycleChange.Listener) = objectLifecycleEmitter.off(listener) - - override fun offAll() = objectLifecycleEmitter.off() - - override fun objectLifecycleChanged(newLifecycle: ObjectLifecycle) { - objectLifecycleToEventMap[newLifecycle]?.let { objectLifecycleEvent -> - objectLifecycleEmitter.emit(objectLifecycleEvent) - } - } - - override fun disposeObjectLifecycleListeners() = offAll() -} - -private class ObjectLifecycleEmitter : EventEmitter() { - private val tag = "ObjectLifecycleEmitter" - override fun apply(listener: ObjectLifecycleChange.Listener?, event: ObjectLifecycleEvent?, vararg args: Any?) { - try { - event?.let { listener?.onLifecycleEvent(it) } - ?: Log.w(tag, "Null event passed to ObjectLifecycleChange listener callback") - } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing listener callback for event: $event", t) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt deleted file mode 100644 index f6a9ee6c6..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt +++ /dev/null @@ -1,137 +0,0 @@ -package io.ably.lib.objects.type.livecounter - -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.ObjectUpdate -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.objects.type.counter.LiveCounter -import io.ably.lib.objects.type.counter.LiveCounterChange -import io.ably.lib.objects.type.counter.LiveCounterUpdate -import io.ably.lib.objects.type.noOp -import java.util.concurrent.atomic.AtomicReference -import io.ably.lib.util.Log -import io.ably.lib.util.SystemClock -import kotlinx.coroutines.runBlocking - -/** - * @spec RTLC1/RTLC2 - LiveCounter implementation extends BaseRealtimeObject - */ -internal class DefaultLiveCounter private constructor( - objectId: String, - private val realtimeObjects: DefaultRealtimeObjects, -) : LiveCounter, BaseRealtimeObject(objectId, ObjectType.Counter, realtimeObjects.clock) { - - override val tag = "LiveCounter" - - /** - * Thread-safe reference to hold the counter data value. - * Accessed from public API for LiveCounter and updated by LiveCounterManager. - */ - internal val data = AtomicReference(0.0) // RTLC3 - - /** - * liveCounterManager instance for managing LiveCounter operations - */ - private val liveCounterManager = LiveCounterManager(this) - - private val channelName = realtimeObjects.channelName - private val adapter: ObjectsAdapter get() = realtimeObjects.adapter - private val asyncScope get() = realtimeObjects.asyncScope - - override fun increment(amount: Number) = runBlocking { incrementAsync(amount.toDouble()) } - - override fun decrement(amount: Number) = runBlocking { incrementAsync(-amount.toDouble()) } - - override fun incrementAsync(amount: Number, callback: ObjectsCallback) { - asyncScope.launchWithVoidCallback(callback) { incrementAsync(amount.toDouble()) } - } - - override fun decrementAsync(amount: Number, callback: ObjectsCallback) { - asyncScope.launchWithVoidCallback(callback) { incrementAsync(-amount.toDouble()) } - } - - override fun value(): Double { - adapter.throwIfInvalidAccessApiConfiguration(channelName) - return data.get() - } - - override fun subscribe(listener: LiveCounterChange.Listener): ObjectsSubscription { - adapter.throwIfInvalidAccessApiConfiguration(channelName) - return liveCounterManager.subscribe(listener) - } - - override fun unsubscribe(listener: LiveCounterChange.Listener) = liveCounterManager.unsubscribe(listener) - - override fun unsubscribeAll() = liveCounterManager.unsubscribeAll() - - override fun validate(state: ObjectState) = liveCounterManager.validate(state) - - private suspend fun incrementAsync(amount: Double) { - // RTLC12b, RTLC12c, RTLC12d - Validate write API configuration - adapter.throwIfInvalidWriteApiConfiguration(channelName) - - // RTLC12e1 - Validate input parameter - if (amount.isNaN() || amount.isInfinite()) { - throw invalidInputError("Counter value increment should be a valid number") - } - - // RTLC12e2, RTLC12e3, RTLC12e4 - Create ObjectMessage with the COUNTER_INC operation - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = objectId, - counterInc = CounterInc(number = amount) - ) - ) - - // RTLC12g - publish and apply locally on ACK - realtimeObjects.publishAndApply(arrayOf(msg)) - } - - override fun applyObjectState(objectState: ObjectState, message: ObjectMessage): LiveCounterUpdate { - return liveCounterManager.applyState(objectState, message.serialTimestamp) - } - - override fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage): Boolean { - return liveCounterManager.applyOperation(operation, message.serialTimestamp) - } - - override fun clearData(): LiveCounterUpdate { - return liveCounterManager.calculateUpdateFromDataDiff(data.get(), 0.0).apply { data.set(0.0) } - } - - override fun notifyUpdated(update: ObjectUpdate) { - if (update.noOp) { - return - } - Log.v(tag, "Object $objectId updated: $update") - liveCounterManager.notify(update as LiveCounterUpdate) - } - - override fun onGCInterval(gcGracePeriod: Long) { - // Nothing to GC for a counter object - return - } - - companion object { - /** - * Creates a zero-value counter object. - * @spec RTLC4 - Returns LiveCounter with 0 value - */ - internal fun zeroValue(objectId: String, realtimeObjects: DefaultRealtimeObjects): DefaultLiveCounter { - return DefaultLiveCounter(objectId, realtimeObjects) - } - - /** - * Creates initial value payload for counter creation. - * Spec: RTO12f12 - */ - internal fun initialValue(count: Number): CounterCreate { - return CounterCreate(count = count.toDouble()) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt deleted file mode 100644 index a1940dc04..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.ably.lib.objects.type.livecounter - -import io.ably.lib.objects.ObjectsSubscription -import io.ably.lib.objects.type.counter.LiveCounterChange -import io.ably.lib.objects.type.counter.LiveCounterUpdate -import io.ably.lib.util.EventEmitter -import io.ably.lib.util.Log - -internal val noOpCounterUpdate = LiveCounterUpdate() - -/** - * Interface for handling live counter changes by notifying subscribers of updates. - * Implementations typically propagate updates through event emission to registered listeners. - */ -internal interface HandlesLiveCounterChange { - /** - * Notifies all registered listeners about a counter update by propagating the change through the event system. - * This method is called when counter data changes and triggers the emission of update events to subscribers. - */ - fun notify(update: LiveCounterUpdate) -} - -internal abstract class LiveCounterChangeCoordinator: LiveCounterChange, HandlesLiveCounterChange { - private val counterChangeEmitter = LiveCounterChangeEmitter() - - override fun subscribe(listener: LiveCounterChange.Listener): ObjectsSubscription { - counterChangeEmitter.on(listener) - return ObjectsSubscription { - counterChangeEmitter.off(listener) - } - } - - override fun unsubscribe(listener: LiveCounterChange.Listener) = counterChangeEmitter.off(listener) - - override fun unsubscribeAll() = counterChangeEmitter.off() - - override fun notify(update: LiveCounterUpdate) = counterChangeEmitter.emit(update) -} - -private class LiveCounterChangeEmitter : EventEmitter() { - private val tag = "LiveCounterChangeEmitter" - - override fun apply(listener: LiveCounterChange.Listener?, event: LiveCounterUpdate?, vararg args: Any?) { - try { - event?.let { listener?.onUpdated(it) } - ?: Log.w(tag, "Null event passed to LiveCounterChange listener callback") - } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing listener callback for event: $event", t) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt deleted file mode 100644 index b9c35bc37..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt +++ /dev/null @@ -1,134 +0,0 @@ -package io.ably.lib.objects.type.livecounter - -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.objectError -import io.ably.lib.objects.type.counter.LiveCounterUpdate -import io.ably.lib.util.Log - -internal class LiveCounterManager(private val liveCounter: DefaultLiveCounter): LiveCounterChangeCoordinator() { - - private val objectId = liveCounter.objectId - - private val tag = "LiveCounterManager" - - /** - * @spec RTLC6 - Overrides counter data with state from sync - */ - internal fun applyState(objectState: ObjectState, serialTimestamp: Long?): LiveCounterUpdate { - val previousData = liveCounter.data.get() - - if (objectState.tombstone) { - liveCounter.tombstone(serialTimestamp) - } else { - // override data for this object with data from the object state - liveCounter.createOperationIsMerged = false // RTLC6b - liveCounter.data.set(objectState.counter?.count ?: 0.0) // RTLC6c - - // RTLC6d - objectState.createOp?.let { createOp -> - mergeInitialDataFromCreateOperation(createOp) - } - } - - return calculateUpdateFromDataDiff(previousData, liveCounter.data.get()) - } - - /** - * @spec RTLC7 - Applies operations to LiveCounter - */ - internal fun applyOperation(operation: ObjectOperation, serialTimestamp: Long?): Boolean { - return when (operation.action) { - ObjectOperationAction.CounterCreate -> { - val update = applyCounterCreate(operation) // RTLC7d1 - liveCounter.notifyUpdated(update) // RTLC7d1a - true // RTLC7d1b - } - ObjectOperationAction.CounterInc -> { - if (operation.counterInc != null) { - val update = applyCounterInc(operation.counterInc) // RTLC7d2 - liveCounter.notifyUpdated(update) // RTLC7d2a - true // RTLC7d2b - } else { - throw objectError("No payload found for ${operation.action} op for LiveCounter objectId=${objectId}") - } - } - ObjectOperationAction.ObjectDelete -> { - val update = liveCounter.tombstone(serialTimestamp) - liveCounter.notifyUpdated(update) - true // RTLC7d4b - } - else -> { - Log.w(tag, "Invalid ${operation.action} op for LiveCounter objectId=${objectId}") // RTLC7d3 - false - } - } - } - - /** - * @spec RTLC8 - Applies counter create operation - */ - private fun applyCounterCreate(operation: ObjectOperation): LiveCounterUpdate { - if (liveCounter.createOperationIsMerged) { - // RTLC8b - // There can't be two different create operation for the same object id, because the object id - // fully encodes that operation. This means we can safely ignore any new incoming create operations - // if we already merged it once. - Log.v( - tag, - "Skipping applying COUNTER_CREATE op on a counter instance as it was already applied before; objectId=$objectId" - ) - return noOpCounterUpdate // RTLC8c - } - - return mergeInitialDataFromCreateOperation(operation) // RTLC8c - } - - /** - * @spec RTLC9 - Applies counter increment operation - */ - private fun applyCounterInc(counterInc: CounterInc): LiveCounterUpdate { - val amount = counterInc.number - val previousValue = liveCounter.data.get() - liveCounter.data.set(previousValue + amount) // RTLC9f - return LiveCounterUpdate(amount) - } - - internal fun calculateUpdateFromDataDiff(prevData: Double, newData: Double): LiveCounterUpdate { - return LiveCounterUpdate(newData - prevData) - } - - /** - * @spec RTLC16 - Merges initial data from create operation - */ - private fun mergeInitialDataFromCreateOperation(operation: ObjectOperation): LiveCounterUpdate { - // if a counter object is missing for the COUNTER_CREATE op, the initial value is implicitly 0 in this case. - // note that it is intentional to SUM the incoming count from the create op. - // if we got here, it means that current counter instance is missing the initial value in its data reference, - // which we're going to add now. - val count = operation.counterCreateWithObjectId?.derivedFrom?.count - ?: operation.counterCreate?.count - ?: 0.0 - val previousValue = liveCounter.data.get() - liveCounter.data.set(previousValue + count) // RTLC16 - liveCounter.createOperationIsMerged = true // RTLC16 - return LiveCounterUpdate(count) - } - - internal fun validate(state: ObjectState) { - liveCounter.validateObjectId(state.objectId) - state.createOp?.let { createOp -> - liveCounter.validateObjectId(createOp.objectId) - validateCounterCreateAction(createOp.action) - } - } - - private fun validateCounterCreateAction(action: ObjectOperationAction) { - if (action != ObjectOperationAction.CounterCreate) { - throw objectError("Invalid create operation action $action for LiveCounter objectId=${objectId}") - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt deleted file mode 100644 index da5cee9b4..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt +++ /dev/null @@ -1,247 +0,0 @@ -package io.ably.lib.objects.type.livemap - -import io.ably.lib.objects.* -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.ObjectUpdate -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.objects.type.map.LiveMap -import io.ably.lib.objects.type.map.LiveMapChange -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.objects.type.map.LiveMapValue -import io.ably.lib.objects.type.noOp -import io.ably.lib.util.Log -import io.ably.lib.util.SystemClock -import kotlinx.coroutines.runBlocking -import java.util.Base64 -import java.util.concurrent.ConcurrentHashMap -import java.util.AbstractMap - -/** - * @spec RTLM1/RTLM2 - LiveMap implementation extends BaseRealtimeObject - */ -internal class DefaultLiveMap private constructor( - objectId: String, - private val realtimeObjects: DefaultRealtimeObjects, - internal val semantics: ObjectsMapSemantics = ObjectsMapSemantics.LWW -) : LiveMap, BaseRealtimeObject(objectId, ObjectType.Map, realtimeObjects.clock) { - - override val tag = "LiveMap" - - /** - * ConcurrentHashMap for thread-safe access from public APIs in LiveMap and LiveMapManager. - */ - internal val data = ConcurrentHashMap() - - /** @spec RTLM25 */ - internal var clearTimeserial: String? = null - - /** - * LiveMapManager instance for managing LiveMap operations - */ - private val liveMapManager = LiveMapManager(this) - - private val channelName = realtimeObjects.channelName - private val adapter: ObjectsAdapter get() = realtimeObjects.adapter - internal val objectsPool: ObjectsPool get() = realtimeObjects.objectsPool - private val asyncScope get() = realtimeObjects.asyncScope - - override fun get(keyName: String): LiveMapValue? { - adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM5b, RTLM5c - if (isTombstoned) { - return null - } - data[keyName]?.let { liveMapEntry -> - return liveMapEntry.getResolvedValue(objectsPool) - } - return null // RTLM5d1 - } - - override fun entries(): Iterable> { - adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM11b, RTLM11c - - return sequence> { - for ((key, entry) in data.entries) { - val value = entry.getResolvedValue(objectsPool) // RTLM11d, RTLM11d2 - value?.let { - yield(AbstractMap.SimpleImmutableEntry(key, it)) - } - } - }.asIterable() - } - - override fun keys(): Iterable { - val iterableEntries = entries() - return sequence { - for (entry in iterableEntries) { - yield(entry.key) // RTLM12b - } - }.asIterable() - } - - override fun values(): Iterable { - val iterableEntries = entries() - return sequence { - for (entry in iterableEntries) { - yield(entry.value) // RTLM13b - } - }.asIterable() - } - - override fun size(): Long { - adapter.throwIfInvalidAccessApiConfiguration(channelName) - return data.values.count { !it.isEntryOrRefTombstoned(objectsPool) }.toLong() // RTLM10d - } - - override fun set(keyName: String, value: LiveMapValue) = runBlocking { setAsync(keyName, value) } - - override fun remove(keyName: String) = runBlocking { removeAsync(keyName) } - - override fun setAsync(keyName: String, value: LiveMapValue, callback: ObjectsCallback) { - asyncScope.launchWithVoidCallback(callback) { setAsync(keyName, value) } - } - - override fun removeAsync(keyName: String, callback: ObjectsCallback) { - asyncScope.launchWithVoidCallback(callback) { removeAsync(keyName) } - } - - override fun validate(state: ObjectState) = liveMapManager.validate(state) - - override fun subscribe(listener: LiveMapChange.Listener): ObjectsSubscription { - adapter.throwIfInvalidAccessApiConfiguration(channelName) - return liveMapManager.subscribe(listener) - } - - override fun unsubscribe(listener: LiveMapChange.Listener) = liveMapManager.unsubscribe(listener) - - override fun unsubscribeAll() = liveMapManager.unsubscribeAll() - - private suspend fun setAsync(keyName: String, value: LiveMapValue) { - // RTLM20b, RTLM20c, RTLM20d - Validate write API configuration - adapter.throwIfInvalidWriteApiConfiguration(channelName) - - // Validate input parameters - if (keyName.isEmpty()) { - throw invalidInputError("Map key should not be empty") - } - - // RTLM20e - Create ObjectMessage with the MAP_SET operation - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = objectId, - mapSet = MapSet( - key = keyName, - value = fromLiveMapValue(value) - ) - ) - ) - - // RTLM20g - publish and apply locally on ACK - realtimeObjects.publishAndApply(arrayOf(msg)) - } - - private suspend fun removeAsync(keyName: String) { - // RTLM21b, RTLM21cm RTLM21d - Validate write API configuration - adapter.throwIfInvalidWriteApiConfiguration(channelName) - - // Validate input parameter - if (keyName.isEmpty()) { - throw invalidInputError("Map key should not be empty") - } - - // RTLM21e - Create ObjectMessage with the MAP_REMOVE operation - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = objectId, - mapRemove = MapRemove(key = keyName) - ) - ) - - // RTLM21g - publish and apply locally on ACK - realtimeObjects.publishAndApply(arrayOf(msg)) - } - - override fun applyObjectState(objectState: ObjectState, message: ObjectMessage): LiveMapUpdate { - return liveMapManager.applyState(objectState, message.serialTimestamp) - } - - override fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage): Boolean { - return liveMapManager.applyOperation(operation, message.serial, message.serialTimestamp) - } - - override fun clearData(): LiveMapUpdate { - clearTimeserial = null // RTLM4 - return liveMapManager.calculateUpdateFromDataDiff(data.toMap(), emptyMap()) - .apply { data.clear() } - } - - override fun notifyUpdated(update: ObjectUpdate) { - if (update.noOp) { - return - } - Log.v(tag, "Object $objectId updated: $update") - liveMapManager.notify(update as LiveMapUpdate) - } - - override fun onGCInterval(gcGracePeriod: Long) { - data.entries.removeIf { (_, entry) -> entry.isEligibleForGc(gcGracePeriod, clock) } - } - - companion object { - /** - * Creates a zero-value map object. - * @spec RTLM4 - Returns LiveMap with empty map data - */ - internal fun zeroValue(objectId: String, objects: DefaultRealtimeObjects): DefaultLiveMap { - return DefaultLiveMap(objectId, objects) - } - - /** - * Creates a MapCreate payload from map entries. - * Spec: RTO11f14 - */ - internal fun initialValue(entries: MutableMap): MapCreate { - return MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = entries.mapValues { (_, value) -> - ObjectsMapEntry( - tombstone = false, - data = fromLiveMapValue(value) - ) - } - ) - } - - /** - * Spec: RTLM20e5 - */ - private fun fromLiveMapValue(value: LiveMapValue): ObjectData { - return when { - value.isLiveMap || value.isLiveCounter -> - ObjectData(objectId = (value.value as BaseRealtimeObject).objectId) - value.isBoolean -> - ObjectData(boolean = value.asBoolean) - value.isBinary -> - ObjectData(bytes = Base64.getEncoder().encodeToString(value.asBinary)) - value.isNumber -> - ObjectData(number = value.asNumber.toDouble()) - value.isString -> - ObjectData(string = value.asString) - value.isJsonObject -> - ObjectData(json = value.asJsonObject) - value.isJsonArray -> - ObjectData(json = value.asJsonArray) - else -> - throw IllegalArgumentException("Unsupported value type") - } - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt deleted file mode 100644 index 8bed43497..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.ably.lib.objects.type.livemap - -import io.ably.lib.objects.ObjectsSubscription -import io.ably.lib.objects.type.map.LiveMapChange -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.util.EventEmitter -import io.ably.lib.util.Log - -internal val noOpMapUpdate = LiveMapUpdate() - -/** - * Interface for handling live map changes by notifying subscribers of updates. - * Implementations typically propagate updates through event emission to registered listeners. - */ -internal interface HandlesLiveMapChange { - /** - * Notifies all registered listeners about a map update by propagating the change through the event system. - * This method is called when map data changes and triggers the emission of update events to subscribers. - */ - fun notify(update: LiveMapUpdate) -} - -internal abstract class LiveMapChangeCoordinator: LiveMapChange, HandlesLiveMapChange { - private val mapChangeEmitter = LiveMapChangeEmitter() - - override fun subscribe(listener: LiveMapChange.Listener): ObjectsSubscription { - mapChangeEmitter.on(listener) - return ObjectsSubscription { - mapChangeEmitter.off(listener) - } - } - - override fun unsubscribe(listener: LiveMapChange.Listener) = mapChangeEmitter.off(listener) - - override fun unsubscribeAll() = mapChangeEmitter.off() - - override fun notify(update: LiveMapUpdate) = mapChangeEmitter.emit(update) -} - -private class LiveMapChangeEmitter : EventEmitter() { - private val tag = "LiveMapChangeEmitter" - - override fun apply(listener: LiveMapChange.Listener?, event: LiveMapUpdate?, vararg args: Any?) { - try { - event?.let { listener?.onUpdated(it) } - ?: Log.w(tag, "Null event passed to LiveMapChange listener callback") - } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing listener callback for event: $event", t) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt deleted file mode 100644 index 2b21a7f2f..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt +++ /dev/null @@ -1,86 +0,0 @@ -package io.ably.lib.objects.type.livemap - -import io.ably.lib.objects.* -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectsPool -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.objects.type.counter.LiveCounter -import io.ably.lib.objects.type.map.LiveMap -import io.ably.lib.objects.type.map.LiveMapValue -import io.ably.lib.util.Clock -import java.util.Base64 - -/** - * @spec RTLM3 - Map data structure storing entries - */ -internal data class LiveMapEntry( - val isTombstoned: Boolean = false, - val tombstonedAt: Long? = null, - val timeserial: String? = null, - val data: ObjectData? = null -) - -/** - * Checks if entry is directly tombstoned or references a tombstoned object. Spec: RTLM14 - * @param objectsPool The object pool containing referenced DefaultRealtimeObjects - */ -internal fun LiveMapEntry.isEntryOrRefTombstoned(objectsPool: ObjectsPool): Boolean { - if (isTombstoned) { - return true // RTLM14a - } - data?.objectId?.let { refId -> // RTLM5d2f -has an objectId reference - objectsPool.get(refId)?.let { refObject -> - if (refObject.isTombstoned) { - return true - } - } - } - return false // RTLM14b -} - -/** - * Returns value as is if object data stores a primitive type or - * a reference to another RealtimeObject from the pool if it stores an objectId. - */ -internal fun LiveMapEntry.getResolvedValue(objectsPool: ObjectsPool): LiveMapValue? { - if (isTombstoned) { return null } // RTLM5d2a - - data?.let { d -> // RTLM5d2b, RTLM5d2c, RTLM5d2d, RTLM5d2e - d.string?.let { return LiveMapValue.of(it) } - d.number?.let { return LiveMapValue.of(it) } - d.boolean?.let { return LiveMapValue.of(it) } - d.bytes?.let { return LiveMapValue.of(Base64.getDecoder().decode(it)) } - d.json?.let { parsed -> - return when { - parsed.isJsonObject -> LiveMapValue.of(parsed.asJsonObject) - parsed.isJsonArray -> LiveMapValue.of(parsed.asJsonArray) - else -> null - } - } - d.objectId?.let { refId -> // RTLM5d2f - has an objectId reference - objectsPool.get(refId)?.let { refObject -> - if (refObject.isTombstoned) { - return null // tombstoned objects must not be surfaced to the end users - } - return fromRealtimeObject(refObject) // RTLM5d2f2 - } - } - } - return null // RTLM5d2g, RTLM5d2f1 -} - -/** - * Extension function to check if a LiveMapEntry is expired and ready for garbage collection - */ -internal fun LiveMapEntry.isEligibleForGc(gcGracePeriod: Long, clock: Clock): Boolean { - val currentTime = clock.currentTimeMillis() - return isTombstoned && tombstonedAt?.let { currentTime - it >= gcGracePeriod } == true -} - -private fun fromRealtimeObject(realtimeObject: BaseRealtimeObject): LiveMapValue { - return when (realtimeObject.objectType) { - ObjectType.Map -> LiveMapValue.of(realtimeObject as LiveMap) - ObjectType.Counter -> LiveMapValue.of(realtimeObject as LiveCounter) - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt deleted file mode 100644 index 71cd4e4a2..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt +++ /dev/null @@ -1,410 +0,0 @@ -package io.ably.lib.objects.type.livemap - -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.isInvalid -import io.ably.lib.objects.objectError -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.objects.type.noOp -import io.ably.lib.util.Log - -internal class LiveMapManager(private val liveMap: DefaultLiveMap): LiveMapChangeCoordinator() { - - private val objectId = liveMap.objectId - - private val tag = "LiveMapManager" - - /** - * @spec RTLM6 - Overrides object data with state from sync - */ - internal fun applyState(objectState: ObjectState, serialTimestamp: Long?): LiveMapUpdate { - val previousData = liveMap.data.toMap() - - if (objectState.tombstone) { - liveMap.tombstone(serialTimestamp) - } else { - // override data for this object with data from the object state - liveMap.createOperationIsMerged = false // RTLM6b - liveMap.data.clear() - - liveMap.clearTimeserial = objectState.map?.clearTimeserial // RTLM6i - - objectState.map?.entries?.forEach { (key, entry) -> - liveMap.data[key] = LiveMapEntry( - isTombstoned = entry.tombstone ?: false, - tombstonedAt = if (entry.tombstone == true) entry.serialTimestamp ?: liveMap.clock.currentTimeMillis() else null, - timeserial = entry.timeserial, - data = entry.data - ) - } // RTLM6c - - // RTLM6d - objectState.createOp?.let { createOp -> - mergeInitialDataFromCreateOperation(createOp) - } - } - - return calculateUpdateFromDataDiff(previousData, liveMap.data.toMap()) - } - - /** - * @spec RTLM15 - Applies operations to LiveMap - */ - internal fun applyOperation(operation: ObjectOperation, serial: String?, serialTimestamp: Long?): Boolean { - return when (operation.action) { - ObjectOperationAction.MapCreate -> { - val update = applyMapCreate(operation) // RTLM15d1 - liveMap.notifyUpdated(update) // RTLM15d1a - true // RTLM15d1b - } - ObjectOperationAction.MapSet -> { - if (operation.mapSet != null) { - val update = applyMapSet(operation.mapSet, serial) // RTLM15d2 - liveMap.notifyUpdated(update) // RTLM15d2a - true // RTLM15d2b - } else { - throw objectError("No payload found for ${operation.action} op for LiveMap objectId=${objectId}") - } - } - ObjectOperationAction.MapRemove -> { - if (operation.mapRemove != null) { - val update = applyMapRemove(operation.mapRemove, serial, serialTimestamp) // RTLM15d3 - liveMap.notifyUpdated(update) // RTLM15d3a - true // RTLM15d3b - } else { - throw objectError("No payload found for ${operation.action} op for LiveMap objectId=${objectId}") - } - } - ObjectOperationAction.ObjectDelete -> { - val update = liveMap.tombstone(serialTimestamp) - liveMap.notifyUpdated(update) - true // RTLM15d5b - } - ObjectOperationAction.MapClear -> { - val update = applyMapClear(serial) // RTLM15d8 - liveMap.notifyUpdated(update) // RTLM15d8a - true // RTLM15d8b - } - else -> { - Log.w(tag, "Invalid ${operation.action} op for LiveMap objectId=${objectId}") // RTLM15d4 - false - } - } - } - - /** - * @spec RTLM16 - Applies map create operation - */ - private fun applyMapCreate(operation: ObjectOperation): LiveMapUpdate { - if (liveMap.createOperationIsMerged) { - // RTLM16b - // There can't be two different create operation for the same object id, because the object id - // fully encodes that operation. This means we can safely ignore any new incoming create operations - // if we already merged it once. - Log.v( - tag, - "Skipping applying MAP_CREATE op on a map instance as it was already applied before; objectId=${objectId}" - ) - return noOpMapUpdate - } - - validateMapSemantics(getEffectiveMapCreate(operation)?.semantics) // RTLM16c - - return mergeInitialDataFromCreateOperation(operation) // RTLM16d - } - - /** - * @spec RTLM7 - Applies MAP_SET operation to LiveMap - */ - private fun applyMapSet( - mapSet: MapSet, // RTLM7d1 - timeSerial: String?, // RTLM7d2 - ): LiveMapUpdate { - // RTLM7h - skip if operation is older than the last MAP_CLEAR - val clearSerial = liveMap.clearTimeserial - if (clearSerial != null && (timeSerial == null || clearSerial >= timeSerial)) { - Log.v(tag, - "Skipping MAP_SET for key=\"${mapSet.key}\": op serial $timeSerial <= clear serial $clearSerial; objectId=$objectId") - return noOpMapUpdate - } - - val existingEntry = liveMap.data[mapSet.key] - - // RTLM7a - if (existingEntry != null && !canApplyMapOperation(existingEntry.timeserial, timeSerial)) { - // RTLM7a1 - the operation's serial <= the entry's serial, ignore the operation - Log.v(tag, - "Skipping update for key=\"${mapSet.key}\": op serial $timeSerial <= entry serial ${existingEntry.timeserial};" + - " objectId=${objectId}" - ) - return noOpMapUpdate - } - - if (mapSet.value.isInvalid()) { - throw objectError("Invalid object data for MAP_SET op on objectId=${objectId} on key=${mapSet.key}") - } - - // RTLM7c - mapSet.value.objectId?.let { - // this MAP_SET op is setting a key to point to another object via its object id, - // but it is possible that we don't have the corresponding object in the pool yet (for example, we haven't seen the *_CREATE op for it). - // we don't want to return undefined from this map's .get() method even if we don't have the object, - // so instead we create a zero-value object for that object id if it not exists. - liveMap.objectsPool.createZeroValueObjectIfNotExists(it) // RTLM7c1 - } - - if (existingEntry != null) { - // RTLM7a2 - Replace existing entry with new one instead of mutating - liveMap.data[mapSet.key] = LiveMapEntry( - isTombstoned = false, // RTLM7a2c - timeserial = timeSerial, // RTLM7a2b - data = mapSet.value // RTLM7a2a - ) - } else { - // RTLM7b, RTLM7b1 - liveMap.data[mapSet.key] = LiveMapEntry( - isTombstoned = false, // RTLM7b2 - timeserial = timeSerial, - data = mapSet.value - ) - } - - return LiveMapUpdate(mapOf(mapSet.key to LiveMapUpdate.Change.UPDATED)) - } - - /** - * @spec RTLM8 - Applies MAP_REMOVE operation to LiveMap - */ - private fun applyMapRemove( - mapRemove: MapRemove, // RTLM8c1 - timeSerial: String?, // RTLM8c2 - timeStamp: Long?, // RTLM8c3 - ): LiveMapUpdate { - // RTLM8g - skip if operation is older than the last MAP_CLEAR - val clearSerial = liveMap.clearTimeserial - if (clearSerial != null && (timeSerial == null || clearSerial >= timeSerial)) { - Log.v(tag, - "Skipping MAP_REMOVE for key=\"${mapRemove.key}\": op serial $timeSerial <= clear serial $clearSerial; objectId=$objectId") - return noOpMapUpdate - } - - val existingEntry = liveMap.data[mapRemove.key] - - // RTLM8a - if (existingEntry != null && !canApplyMapOperation(existingEntry.timeserial, timeSerial)) { - // RTLM8a1 - the operation's serial <= the entry's serial, ignore the operation - Log.v( - tag, - "Skipping remove for key=\"${mapRemove.key}\": op serial $timeSerial <= entry serial ${existingEntry.timeserial}; " + - "objectId=${objectId}" - ) - return noOpMapUpdate - } - - val tombstonedAt = if (timeStamp != null) timeStamp else { - Log.w( - tag, - "No timestamp provided for MAP_REMOVE op on key=\"${mapRemove.key}\"; using current time as tombstone time; " + - "objectId=${objectId}" - ) - liveMap.clock.currentTimeMillis() - } - - if (existingEntry != null) { - // RTLM8a2 - Replace existing entry with new one instead of mutating - liveMap.data[mapRemove.key] = LiveMapEntry( - isTombstoned = true, // RTLM8a2c - tombstonedAt = tombstonedAt, - timeserial = timeSerial, // RTLM8a2b - data = null // RTLM8a2a - ) - } else { - // RTLM8b, RTLM8b1 - liveMap.data[mapRemove.key] = LiveMapEntry( - isTombstoned = true, // RTLM8b2 - tombstonedAt = tombstonedAt, - timeserial = timeSerial - ) - } - - return LiveMapUpdate(mapOf(mapRemove.key to LiveMapUpdate.Change.REMOVED)) - } - - /** - * @spec RTLM24 - Applies MAP_CLEAR operation to LiveMap - */ - private fun applyMapClear(timeSerial: String?): LiveMapUpdate { - val clearSerial = liveMap.clearTimeserial - - // RTLM24c - skip if existing clear serial is strictly newer than incoming op serial - if (clearSerial != null && (timeSerial == null || clearSerial > timeSerial)) { - Log.v(tag, - "Skipping MAP_CLEAR: op serial $timeSerial <= current clear serial $clearSerial; objectId=$objectId") - return noOpMapUpdate - } - - Log.v(tag, - "Updating clearTimeserial; previous=$clearSerial, new=$timeSerial; objectId=$objectId") - liveMap.clearTimeserial = timeSerial // RTLM24d - - val update = mutableMapOf() - - // RTLM24e - remove all entries whose serial is older than (or equal to missing) the clear serial - liveMap.data.entries.removeIf { - val (key, entry) = it - val entrySerial = entry.timeserial - if (entrySerial == null || (timeSerial != null && timeSerial > entrySerial)) { - update[key] = LiveMapUpdate.Change.REMOVED - true - } else { - false - } - } - - return LiveMapUpdate(update) - } - - /** - * For Lww CRDT semantics (the only supported LiveMap semantic) an operation - * Should only be applied if incoming serial is strictly greater than existing entry's serial. - * @spec RTLM9 - Serial comparison logic for map operations - */ - private fun canApplyMapOperation(existingMapEntrySerial: String?, timeSerial: String?): Boolean { - if (existingMapEntrySerial.isNullOrEmpty() && timeSerial.isNullOrEmpty()) { // RTLM9b - return false - } - if (existingMapEntrySerial.isNullOrEmpty()) { // RTLM9d - If true, means timeSerial is not empty based on previous checks - return true - } - if (timeSerial.isNullOrEmpty()) { // RTLM9c - Check reached here means existingMapEntrySerial is not empty - return false - } - return timeSerial > existingMapEntrySerial // RTLM9e - both are not empty - } - - /** - * @spec RTLM23 - Merges initial data from create operation - */ - private fun getEffectiveMapCreate(operation: ObjectOperation): MapCreate? = - operation.mapCreateWithObjectId?.derivedFrom ?: operation.mapCreate - - private fun mergeInitialDataFromCreateOperation(operation: ObjectOperation): LiveMapUpdate { - val effectiveMapCreate = getEffectiveMapCreate(operation) - if (effectiveMapCreate?.entries.isNullOrEmpty()) { // no map entries in MAP_CREATE op - return noOpMapUpdate - } - - val aggregatedUpdate = mutableListOf() - - // RTLM23a - // in order to apply MAP_CREATE op for an existing map, we should merge their underlying entries keys. - // we can do this by iterating over entries from MAP_CREATE op and apply changes on per-key basis as if we had MAP_SET, MAP_REMOVE operations. - effectiveMapCreate?.entries?.forEach { (key, entry) -> - // for a MAP_CREATE operation we must use the serial value available on an entry, instead of a serial on a message - val opTimeserial = entry.timeserial - val update = if (entry.tombstone == true) { - // RTLM23a2 - entry in MAP_CREATE op is removed, try to apply MAP_REMOVE op - applyMapRemove(MapRemove(key), opTimeserial, entry.serialTimestamp) - } else { - // RTLM23a1 - entry in MAP_CREATE op is not removed, try to set it via MAP_SET op - applyMapSet(MapSet(key, entry.data ?: throw objectError("MAP_SET operation without data")), opTimeserial) - } - - // skip noop updates - if (update.noOp) { - return@forEach - } - - aggregatedUpdate.add(update) - } - - liveMap.createOperationIsMerged = true // RTLM23b - - return LiveMapUpdate( - aggregatedUpdate.map { it.update }.fold(emptyMap()) { acc, map -> acc + map } - ) - } - - internal fun calculateUpdateFromDataDiff( - prevData: Map, - newData: Map - ): LiveMapUpdate { - val update = mutableMapOf() - - // Check for removed entries - for ((key, prevEntry) in prevData) { - if (!prevEntry.isTombstoned && !newData.containsKey(key)) { - update[key] = LiveMapUpdate.Change.REMOVED - } - } - - // Check for added/updated entries - for ((key, newEntry) in newData) { - if (!prevData.containsKey(key)) { - // if property does not exist in current map, but new data has it as non-tombstoned property - got updated - if (!newEntry.isTombstoned) { - update[key] = LiveMapUpdate.Change.UPDATED - } - // otherwise, if new data has this prop tombstoned - do nothing, as property didn't exist anyway - continue - } - - // properties that exist both in current and new map data need to have their values compared to decide on update type - val prevEntry = prevData[key]!! - - // compare tombstones first - if (prevEntry.isTombstoned && !newEntry.isTombstoned) { - // prev prop is tombstoned, but new is not. it means prop was updated to a meaningful value - update[key] = LiveMapUpdate.Change.UPDATED - continue - } - if (!prevEntry.isTombstoned && newEntry.isTombstoned) { - // prev prop is not tombstoned, but new is. it means prop was removed - update[key] = LiveMapUpdate.Change.REMOVED - continue - } - if (prevEntry.isTombstoned && newEntry.isTombstoned) { - // props are tombstoned - treat as noop, as there is no data to compare - continue - } - - // both props exist and are not tombstoned, need to compare values to see if it was changed - val valueChanged = prevEntry.data != newEntry.data - if (valueChanged) { - update[key] = LiveMapUpdate.Change.UPDATED - continue - } - } - - return LiveMapUpdate(update) - } - - internal fun validate(state: ObjectState) { - liveMap.validateObjectId(state.objectId) - validateMapSemantics(state.map?.semantics) - state.createOp?.let { createOp -> - liveMap.validateObjectId(createOp.objectId) - validateMapCreateAction(createOp.action) - validateMapSemantics(getEffectiveMapCreate(createOp)?.semantics) - } - } - - private fun validateMapCreateAction(action: ObjectOperationAction) { - if (action != ObjectOperationAction.MapCreate) { - throw objectError("Invalid create operation action $action for LiveMap objectId=${objectId}") - } - } - - private fun validateMapSemantics(semantics: ObjectsMapSemantics?) { - if (semantics != liveMap.semantics) { - throw objectError( - "Invalid object: incoming object map semantics=$semantics; current map semantics=${ObjectsMapSemantics.LWW}" - ) - } - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/TestUtils.kt similarity index 98% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/TestUtils.kt index a91f0e9cf..65c712463 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/TestUtils.kt @@ -1,4 +1,4 @@ -package io.ably.lib.objects +package io.ably.lib.liveobjects import java.lang.reflect.Field import kotlinx.coroutines.Dispatchers diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/DefaultRealtimeObjectTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/DefaultRealtimeObjectTest.kt new file mode 100644 index 000000000..7fc44f60a --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/DefaultRealtimeObjectTest.kt @@ -0,0 +1,41 @@ +package io.ably.lib.liveobjects.integration + +import io.ably.lib.liveobjects.assertWaiter +import io.ably.lib.liveobjects.integration.setup.IntegrationTest +import io.ably.lib.realtime.ChannelState +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * Basic integration tests for the path-based LiveObjects implementation. + * + * These exercise the sandbox setup/teardown (see [IntegrationTest]) together with the + * realtime connection and channel lifecycle against a real sandbox app. The path-based + * public Objects API (`channel.object`) is not yet wired to the plugin on this branch - + * it currently resolves to the `RealtimeObject.Unavailable` null-object guard - so these + * tests assert connectivity and the always-present `object` accessor rather than object + * functionality. Functional object tests will be added as the implementation lands. + */ +class DefaultRealtimeObjectTest : IntegrationTest() { + + @Test + fun testRealtimeChannelAttachesOnSandbox() = runTest { + val channelName = generateChannelName() + val channel = getRealtimeChannel(channelName) + assertNotNull(channel) + + channel.attach() + assertWaiter { channel.state == ChannelState.attached } + assertEquals(ChannelState.attached, channel.state) + } + + @Test + fun testRealtimeChannelExposesObjectAccessor() = runTest { + val channelName = generateChannelName() + val channel = getRealtimeChannel(channelName) + // `channel.object` is always non-null (null-object guard) even without the plugin installed. + assertNotNull(channel.`object`) + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/PayloadBuilder.kt similarity index 72% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/PayloadBuilder.kt index 283d11a4f..2f025059c 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/PayloadBuilder.kt @@ -1,10 +1,10 @@ -package io.ably.lib.objects.integration.helpers +package io.ably.lib.liveobjects.integration.helpers import com.google.gson.JsonObject -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.generateNonce -import io.ably.lib.objects.serialization.gson +import io.ably.lib.liveobjects.generateNonce +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.serialization.gson internal object PayloadBuilder { /** @@ -12,11 +12,11 @@ internal object PayloadBuilder { * Maps ObjectOperationAction enum values to their string representations. */ private val ACTION_STRINGS = mapOf( - ObjectOperationAction.MapCreate to "MAP_CREATE", - ObjectOperationAction.MapSet to "MAP_SET", - ObjectOperationAction.MapRemove to "MAP_REMOVE", - ObjectOperationAction.CounterCreate to "COUNTER_CREATE", - ObjectOperationAction.CounterInc to "COUNTER_INC", + WireObjectOperationAction.MapCreate to "MAP_CREATE", + WireObjectOperationAction.MapSet to "MAP_SET", + WireObjectOperationAction.MapRemove to "MAP_REMOVE", + WireObjectOperationAction.CounterCreate to "COUNTER_CREATE", + WireObjectOperationAction.CounterInc to "COUNTER_INC", ) /** @@ -28,11 +28,11 @@ internal object PayloadBuilder { */ internal fun mapCreateRestOp( objectId: String? = null, - data: Map? = null, + data: Map? = null, nonce: String? = null, ): JsonObject { val opBody = JsonObject().apply { - addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapCreate]) + addProperty("operation", ACTION_STRINGS[WireObjectOperationAction.MapCreate]) } if (data != null) { @@ -51,9 +51,9 @@ internal object PayloadBuilder { /** * Creates a MAP_SET operation payload for REST API. */ - internal fun mapSetRestOp(objectId: String, key: String, value: ObjectData): JsonObject { + internal fun mapSetRestOp(objectId: String, key: String, value: WireObjectData): JsonObject { val opBody = JsonObject().apply { - addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapSet]) + addProperty("operation", ACTION_STRINGS[WireObjectOperationAction.MapSet]) addProperty("objectId", objectId) } @@ -71,7 +71,7 @@ internal object PayloadBuilder { */ internal fun mapRemoveRestOp(objectId: String, key: String): JsonObject { val opBody = JsonObject().apply { - addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapRemove]) + addProperty("operation", ACTION_STRINGS[WireObjectOperationAction.MapRemove]) addProperty("objectId", objectId) } @@ -96,7 +96,7 @@ internal object PayloadBuilder { nonce: String? = null, ): JsonObject { val opBody = JsonObject().apply { - addProperty("operation", ACTION_STRINGS[ObjectOperationAction.CounterCreate]) + addProperty("operation", ACTION_STRINGS[WireObjectOperationAction.CounterCreate]) } if (number != null) { @@ -119,7 +119,7 @@ internal object PayloadBuilder { */ internal fun counterIncRestOp(objectId: String, number: Double): JsonObject { val opBody = JsonObject().apply { - addProperty("operation", ACTION_STRINGS[ObjectOperationAction.CounterInc]) + addProperty("operation", ACTION_STRINGS[WireObjectOperationAction.CounterInc]) addProperty("objectId", objectId) add("data", JsonObject().apply { addProperty("number", number) diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/RestObjects.kt similarity index 94% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/RestObjects.kt index d06559377..62ef8d7d5 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/RestObjects.kt @@ -1,10 +1,10 @@ -package io.ably.lib.objects.integration.helpers +package io.ably.lib.liveobjects.integration.helpers import com.google.gson.JsonObject -import io.ably.lib.objects.ObjectData +import io.ably.lib.liveobjects.message.WireObjectData import io.ably.lib.rest.AblyRest import io.ably.lib.http.HttpUtils -import io.ably.lib.objects.integration.helpers.fixtures.DataFixtures +import io.ably.lib.liveobjects.integration.helpers.fixtures.DataFixtures import io.ably.lib.types.ClientOptions /** @@ -18,7 +18,7 @@ internal class RestObjects(options: ClientOptions) { * Creates a new map object on the channel with optional initial data. * @return The object ID of the created map */ - internal fun createMap(channelName: String, data: Map? = null): String { + internal fun createMap(channelName: String, data: Map? = null): String { val mapCreateOp = PayloadBuilder.mapCreateRestOp(data = data) return operationRequest(channelName, mapCreateOp).objectId ?: throw Exception("Failed to create map: no objectId returned") @@ -27,7 +27,7 @@ internal class RestObjects(options: ClientOptions) { /** * Sets a value (primitives, JsonObject, JsonArray, etc.) at the specified key in an existing map. */ - internal fun setMapValue(channelName: String, mapObjectId: String, key: String, data: ObjectData) { + internal fun setMapValue(channelName: String, mapObjectId: String, key: String, data: WireObjectData) { val mapCreateOp = PayloadBuilder.mapSetRestOp(mapObjectId, key, data) operationRequest(channelName, mapCreateOp) } diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/CounterFixtures.kt similarity index 96% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/CounterFixtures.kt index a8135a9e4..86f454d41 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/CounterFixtures.kt @@ -1,6 +1,6 @@ -package io.ably.lib.objects.integration.helpers.fixtures +package io.ably.lib.liveobjects.integration.helpers.fixtures -import io.ably.lib.objects.integration.helpers.RestObjects +import io.ably.lib.liveobjects.integration.helpers.RestObjects /** * Creates a comprehensive test fixture object tree focused on user-context counters. diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/DataFixtures.kt similarity index 65% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/DataFixtures.kt index f6f305aba..57227dc7e 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/DataFixtures.kt @@ -1,48 +1,48 @@ -package io.ably.lib.objects.integration.helpers.fixtures +package io.ably.lib.liveobjects.integration.helpers.fixtures import com.google.gson.JsonArray import com.google.gson.JsonObject -import io.ably.lib.objects.ObjectData +import io.ably.lib.liveobjects.message.WireObjectData import java.util.Base64 internal object DataFixtures { /** Test fixture for string value ("stringValue") data type */ - internal val stringData = ObjectData(string = "stringValue") + internal val stringData = WireObjectData(string = "stringValue") /** Test fixture for empty string data type */ - internal val emptyStringData = ObjectData(string = "") + internal val emptyStringData = WireObjectData(string = "") /** Test fixture for binary data containing encoded JSON */ - internal val bytesData = ObjectData( + internal val bytesData = WireObjectData( bytes = Base64.getEncoder().encodeToString("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9".toByteArray())) /** Test fixture for empty binary data (zero-length byte array) */ - internal val emptyBytesData = ObjectData(bytes = Base64.getEncoder().encodeToString(ByteArray(0))) + internal val emptyBytesData = WireObjectData(bytes = Base64.getEncoder().encodeToString(ByteArray(0))) /** Test fixture for maximum safe number value */ - internal val maxSafeNumberData = ObjectData(number = 99999999.0) + internal val maxSafeNumberData = WireObjectData(number = 99999999.0) /** Test fixture for minimum safe number value */ - internal val negativeMaxSafeNumberData = ObjectData(number = -99999999.0) + internal val negativeMaxSafeNumberData = WireObjectData(number = -99999999.0) /** Test fixture for positive number value (1) */ - internal val numberData = ObjectData(number = 1.0) + internal val numberData = WireObjectData(number = 1.0) /** Test fixture for zero number value */ - internal val zeroData = ObjectData(number = 0.0) + internal val zeroData = WireObjectData(number = 0.0) /** Test fixture for boolean true value */ - internal val trueData = ObjectData(boolean = true) + internal val trueData = WireObjectData(boolean = true) /** Test fixture for boolean false value */ - internal val falseData = ObjectData(boolean = false) + internal val falseData = WireObjectData(boolean = false) /** Test fixture for JSON object value with single property */ - internal val objectData = ObjectData(json = JsonObject().apply { addProperty("foo", "bar") }) + internal val objectData = WireObjectData(json = JsonObject().apply { addProperty("foo", "bar") }) /** Test fixture for JSON array value with three string elements */ - internal val arrayData = ObjectData( + internal val arrayData = WireObjectData( json = JsonArray().apply { add("foo") add("bar") @@ -54,13 +54,13 @@ internal object DataFixtures { * Creates an ObjectData instance that references another map object. * @param referencedMapObjectId The object ID of the referenced map */ - internal fun mapRef(referencedMapObjectId: String) = ObjectData(objectId = referencedMapObjectId) + internal fun mapRef(referencedMapObjectId: String) = WireObjectData(objectId = referencedMapObjectId) /** * Creates a test fixture map containing all supported data types and values. * @param referencedMapObjectId The object ID to be used for the map reference entry */ - internal fun mapWithAllValues(referencedMapObjectId: String? = null): Map { + internal fun mapWithAllValues(referencedMapObjectId: String? = null): Map { val baseMap = mapOf( "string" to stringData, "emptyString" to emptyStringData, diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/MapFixtures.kt similarity index 89% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/MapFixtures.kt index 475bbe86a..2d92233e2 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/MapFixtures.kt @@ -1,7 +1,7 @@ -package io.ably.lib.objects.integration.helpers.fixtures +package io.ably.lib.liveobjects.integration.helpers.fixtures -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.integration.helpers.RestObjects +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.integration.helpers.RestObjects /** * Initializes a comprehensive test fixture object tree on the specified channel. @@ -114,10 +114,10 @@ internal fun RestObjects.createUserMapObject(channelName: String): String { val preferencesMapObjectId = createMap( channelName, data = mapOf( - "theme" to ObjectData(string = "dark"), - "notifications" to ObjectData(boolean = true), - "language" to ObjectData(string = "en"), - "maxRetries" to ObjectData(number = 3.0) + "theme" to WireObjectData(string = "dark"), + "notifications" to WireObjectData(boolean = true), + "language" to WireObjectData(string = "en"), + "maxRetries" to WireObjectData(number = 3.0) ) ) @@ -127,8 +127,8 @@ internal fun RestObjects.createUserMapObject(channelName: String): String { data = mapOf( "totalLogins" to DataFixtures.mapRef(loginCounterObjectId), "activeSessions" to DataFixtures.mapRef(sessionCounterObjectId), - "lastLoginTime" to ObjectData(string = "2024-01-01T08:30:00Z"), - "profileViews" to ObjectData(number = 42.0) + "lastLoginTime" to WireObjectData(string = "2024-01-01T08:30:00Z"), + "profileViews" to WireObjectData(number = 42.0) ) ) @@ -174,10 +174,10 @@ internal fun RestObjects.createUserProfileMapObject(channelName: String): String return createMap( channelName, data = mapOf( - "userId" to ObjectData(string = "user123"), - "name" to ObjectData(string = "John Doe"), - "email" to ObjectData(string = "john@example.com"), - "isActive" to ObjectData(boolean = true), + "userId" to WireObjectData(string = "user123"), + "name" to WireObjectData(string = "John Doe"), + "email" to WireObjectData(string = "john@example.com"), + "isActive" to WireObjectData(boolean = true), ) ) } diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/IntegrationTest.kt similarity index 96% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/IntegrationTest.kt index cb46f2f89..1f0a3dba1 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/IntegrationTest.kt @@ -1,6 +1,6 @@ -package io.ably.lib.objects.integration.setup +package io.ably.lib.liveobjects.integration.setup -import io.ably.lib.objects.integration.helpers.RestObjects +import io.ably.lib.liveobjects.integration.helpers.RestObjects import io.ably.lib.realtime.AblyRealtime import io.ably.lib.realtime.Channel import io.ably.lib.types.ChannelMode diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/Sandbox.kt similarity index 94% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/Sandbox.kt index cfcd4ed2b..5cc2f9360 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/Sandbox.kt @@ -1,9 +1,9 @@ -package io.ably.lib.objects.integration.setup +package io.ably.lib.liveobjects.integration.setup import com.google.gson.JsonElement import com.google.gson.JsonParser -import io.ably.lib.objects.ablyException -import io.ably.lib.objects.integration.helpers.RestObjects +import io.ably.lib.liveobjects.ablyException +import io.ably.lib.liveobjects.integration.helpers.RestObjects import io.ably.lib.realtime.* import io.ably.lib.types.ClientOptions import io.ktor.client.* diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/HelpersTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/HelpersTest.kt similarity index 84% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/unit/HelpersTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/HelpersTest.kt index 21f5c6792..76c37d21d 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/HelpersTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/HelpersTest.kt @@ -1,6 +1,10 @@ -package io.ably.lib.objects.unit +package io.ably.lib.liveobjects.unit -import io.ably.lib.objects.* +import io.ably.lib.liveobjects.* +import io.ably.lib.liveobjects.adapter.AblyClientAdapter +import io.ably.lib.liveobjects.clientError +import io.ably.lib.liveobjects.connectionManager +import io.ably.lib.liveobjects.sendAsync import io.ably.lib.realtime.Channel import io.ably.lib.realtime.ChannelState import io.ably.lib.realtime.ChannelStateListener @@ -19,7 +23,7 @@ class HelpersTest { // sendAsync @Test fun testSendAsyncShouldQueueAccordingToClientOptions() = runTest { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connManager = adapter.connectionManager val clientOptions = ClientOptions().apply { queueMessages = false } @@ -41,7 +45,7 @@ class HelpersTest { @Test fun testSendAsyncErrorPropagatesAblyException() = runTest { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connManager = adapter.connectionManager val clientOptions = ClientOptions() @@ -61,7 +65,7 @@ class HelpersTest { @Test fun testOnGCGracePeriodImmediateInvokesBlock() { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connManager = adapter.connectionManager connManager.setPrivateField("objectsGCGracePeriod", 123L) @@ -74,7 +78,7 @@ class HelpersTest { @Test fun testOnGCGracePeriodDeferredInvokesOnConnectedWithValue() { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connManager = adapter.connectionManager val connection = adapter.connection @@ -93,7 +97,7 @@ class HelpersTest { @Test fun testOnGCGracePeriodDeferredInvokesOnConnectedWithNull() { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connection = adapter.connection var value: Long? = null @@ -110,7 +114,7 @@ class HelpersTest { @Test fun testSendAsyncThrowsWhenConnectionManagerThrows() = runTest { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connManager = adapter.connectionManager val clientOptions = ClientOptions() @@ -127,7 +131,7 @@ class HelpersTest { // attachAsync @Test fun testAttachAsyncSuccess() = runTest { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel every { channel.attach(any()) } answers { @@ -141,7 +145,7 @@ class HelpersTest { @Test fun testAttachAsyncError() = runTest { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel every { channel.attach(any()) } answers { @@ -157,7 +161,7 @@ class HelpersTest { // getChannelModes @Test fun testGetChannelModesPrefersChannelModes() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel every { channel.modes } returns arrayOf(ChannelMode.object_publish) @@ -169,7 +173,7 @@ class HelpersTest { @Test fun testGetChannelModesFallsBackToOptions() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel every { channel.modes } returns emptyArray() @@ -181,7 +185,7 @@ class HelpersTest { @Test fun testGetChannelModesReturnsNullWhenNoModes() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel every { channel.modes } returns null @@ -193,7 +197,7 @@ class HelpersTest { @Test fun testGetChannelModesIgnoresEmptyModes() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel every { channel.modes } returns emptyArray() @@ -206,7 +210,7 @@ class HelpersTest { // setChannelSerial @Test fun testSetChannelSerialSetsWhenObjectActionAndNonEmpty() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) val props = ChannelProperties() channel.properties = props @@ -221,7 +225,7 @@ class HelpersTest { @Test fun testSetChannelSerialNoOpForNonObjectActionOrEmpty() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) val props = ChannelProperties() channel.properties = props @@ -243,7 +247,7 @@ class HelpersTest { // ensureAttached @Test fun testEnsureAttachedFromInitializedAttaches() = runTest { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel @@ -260,7 +264,7 @@ class HelpersTest { @Test fun testEnsureAttachedWhenAlreadyAttachedReturns() = runTest { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.attached @@ -272,7 +276,7 @@ class HelpersTest { @Test fun testEnsureAttachedWaitsForAttachingThenAttached() = runTest { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.attaching @@ -291,7 +295,7 @@ class HelpersTest { @Test fun testEnsureAttachedAttachingButReceivesNonAttachedEmitsError() = runTest { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.attaching @@ -304,37 +308,37 @@ class HelpersTest { listener.onChannelStateChanged(stateChange) } val ex = assertFailsWith { adapter.ensureAttached("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex.errorInfo.code) assertTrue(ex.errorInfo.message.contains("Not attached")) verify(exactly = 1) { channel.once(any()) } } @Test fun testEnsureAttachedThrowsForInvalidState() = runTest { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.failed val ex = assertFailsWith { adapter.ensureAttached("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex.errorInfo.code) } // throwIfInvalidAccessApiConfiguration @Test fun testThrowIfInvalidAccessApiConfigurationStateDetached() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.detached val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex.errorInfo.code) } @Test fun testThrowIfInvalidAccessApiConfigurationMissingMode() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.attached @@ -342,37 +346,37 @@ class HelpersTest { every { channel.options } returns ChannelOptions().apply { modes = null } val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelModeRequired.code, ex.errorInfo.code) assertTrue(ex.errorInfo.message.contains("object_subscribe")) } // throwIfInvalidWriteApiConfiguration @Test fun testThrowIfInvalidWriteApiConfigurationEchoDisabled() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val clientOptions = ClientOptions().apply { echoMessages = false } every { adapter.clientOptions } returns clientOptions val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.BadRequest.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.BadRequest.code, ex.errorInfo.code) assertTrue(ex.errorInfo.message.contains("echoMessages")) } @Test fun testThrowIfInvalidWriteApiConfigurationInvalidState() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) every { adapter.clientOptions } returns ClientOptions() val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.suspended val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex.errorInfo.code) } @Test fun testThrowIfInvalidWriteApiConfigurationMissingMode() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) every { adapter.clientOptions } returns ClientOptions() val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel @@ -381,14 +385,14 @@ class HelpersTest { every { channel.options } returns ChannelOptions().apply { modes = null } val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelModeRequired.code, ex.errorInfo.code) assertTrue(ex.errorInfo.message.contains("object_publish")) } // throwIfUnpublishableState @Test fun testThrowIfUnpublishableStateInactiveConnection() { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connManager = adapter.connectionManager every { connManager.isActive } returns false every { connManager.stateErrorInfo } returns serverError("not active").errorInfo @@ -400,7 +404,7 @@ class HelpersTest { @Test fun testThrowIfUnpublishableStateChannelFailed() { - val adapter = getMockObjectsAdapter() + val adapter = getMockAblyClientAdapter() val connManager = adapter.connectionManager every { connManager.isActive } returns true val channel = mockk(relaxed = true) @@ -408,12 +412,12 @@ class HelpersTest { channel.state = ChannelState.failed val ex = assertFailsWith { adapter.throwIfUnpublishableState("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex.errorInfo.code) } @Test fun testAccessConfigThrowsWhenRequiredModeMissing() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.attached @@ -422,13 +426,13 @@ class HelpersTest { every { channel.options } returns ChannelOptions().apply { modes = null } val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelModeRequired.code, ex.errorInfo.code) assertTrue(ex.errorInfo.message.contains("object_subscribe")) } @Test fun testWriteConfigThrowsWhenRequiredModeMissing() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) every { adapter.clientOptions } returns ClientOptions() // echo enabled val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel @@ -437,24 +441,24 @@ class HelpersTest { every { channel.options } returns ChannelOptions().apply { modes = null } val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelModeRequired.code, ex.errorInfo.code) assertTrue(ex.errorInfo.message.contains("object_publish")) } @Test fun testAccessConfigThrowsOnInvalidChannelState() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel channel.state = ChannelState.detached val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex.errorInfo.code) } @Test fun testWriteConfigThrowsOnInvalidChannelStates() { - val adapter = mockk(relaxed = true) + val adapter = mockk(relaxed = true) every { adapter.clientOptions } returns ClientOptions() val channel = mockk(relaxed = true) every { adapter.getChannel("ch") } returns channel @@ -462,12 +466,12 @@ class HelpersTest { // Suspended should be rejected for write config channel.state = ChannelState.suspended val ex1 = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex1.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex1.errorInfo.code) // Failed should also be rejected channel.state = ChannelState.failed val ex2 = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex2.errorInfo.code) + assertEquals(ObjectErrorCode.ChannelStateError.code, ex2.errorInfo.code) } } diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/ObjectMessageSerializationTest.kt similarity index 90% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/ObjectMessageSerializationTest.kt index 776006c41..3a94be91d 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/ObjectMessageSerializationTest.kt @@ -1,11 +1,16 @@ -package io.ably.lib.objects.unit +package io.ably.lib.liveobjects.unit import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.JsonElement import com.google.gson.JsonNull -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.unit.fixtures.* +import io.ably.lib.liveobjects.unit.fixtures.dummyObjectMessageWithBinaryData +import io.ably.lib.liveobjects.unit.fixtures.dummyObjectMessageWithBooleanData +import io.ably.lib.liveobjects.unit.fixtures.dummyObjectMessageWithJsonArrayData +import io.ably.lib.liveobjects.unit.fixtures.dummyObjectMessageWithJsonObjectData +import io.ably.lib.liveobjects.unit.fixtures.dummyObjectMessageWithNumberData +import io.ably.lib.liveobjects.unit.fixtures.dummyObjectMessageWithStringData +import io.ably.lib.liveobjects.message.WireObjectMessage import io.ably.lib.types.ProtocolMessage import io.ably.lib.types.ProtocolMessage.ActionSerializer import io.ably.lib.types.ProtocolSerializer @@ -43,7 +48,7 @@ class ObjectMessageSerializationTest { assertNotNull(deserializedProtoMsg) deserializedProtoMsg.state.zip(objectMessages).forEach { (actual, expected) -> - assertEquals(expected, actual as? ObjectMessage) + assertEquals(expected, actual as? WireObjectMessage) } } @@ -62,7 +67,7 @@ class ObjectMessageSerializationTest { assertNotNull(deserializedProtoMsg) deserializedProtoMsg.state.zip(objectMessages).forEach { (actual, expected) -> - assertEquals(expected, (actual as? ObjectMessage)) + assertEquals(expected, (actual as? WireObjectMessage)) } } @@ -169,11 +174,11 @@ class ObjectMessageSerializationTest { // Check if gson deserialization works correctly deserializedProtoMsg = ProtocolSerializer.fromJSON(protoMsgJsonObject.toString()) - assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? ObjectMessage) + assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? WireObjectMessage) // Check if msgpack deserialization works correctly serializedMsgpackBytes = Serialisation.gsonToMsgpack(protoMsgJsonObject) deserializedProtoMsg = ProtocolSerializer.readMsgpack(serializedMsgpackBytes) - assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? ObjectMessage) + assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? WireObjectMessage) } } diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/ObjectMessageSizeTest.kt similarity index 69% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/ObjectMessageSizeTest.kt index 4c413649e..8449a603c 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/ObjectMessageSizeTest.kt @@ -1,19 +1,24 @@ -package io.ably.lib.objects.unit +package io.ably.lib.liveobjects.unit import com.google.gson.JsonObject -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterCreateWithObjectId -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapCreateWithObjectId -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ensureMessageSizeWithinLimit -import io.ably.lib.objects.size +import io.ably.lib.liveobjects.connectionManager +import io.ably.lib.liveobjects.ensureMessageSizeWithinLimit +import io.ably.lib.liveobjects.message.WireCounterCreate +import io.ably.lib.liveobjects.message.WireCounterCreateWithObjectId +import io.ably.lib.liveobjects.message.WireCounterInc +import io.ably.lib.liveobjects.message.WireMapCreate +import io.ably.lib.liveobjects.message.WireMapCreateWithObjectId +import io.ably.lib.liveobjects.message.WireMapSet +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperation +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.message.WireObjectState +import io.ably.lib.liveobjects.message.WireObjectsCounter +import io.ably.lib.liveobjects.message.WireObjectsMap +import io.ably.lib.liveobjects.message.WireObjectsMapEntry +import io.ably.lib.liveobjects.message.WireObjectsMapSemantics +import io.ably.lib.liveobjects.message.size import io.ably.lib.transport.Defaults import io.ably.lib.types.AblyException import kotlinx.coroutines.test.runTest @@ -24,12 +29,12 @@ import kotlin.test.assertFailsWith class ObjectMessageSizeTest { @Test fun testObjectMessageSizeWithinLimit() = runTest { - val mockAdapter = getMockObjectsAdapter() + val mockAdapter = getMockAblyClientAdapter() mockAdapter.connectionManager.maxMessageSize = Defaults.maxMessageSize // 64 kb assertEquals(65536, mockAdapter.connectionManager.maxMessageSize) // ObjectMessage with all size-contributing fields - val objectMessage = ObjectMessage( + val objectMessage = WireObjectMessage( id = "msg_12345", // Not counted in size calculation timestamp = 1699123456789L, // Not counted in size calculation clientId = "test-client", // Size: 11 bytes (UTF-8 byte length) @@ -38,40 +43,40 @@ class ObjectMessageSizeTest { addProperty("meta", "data") // JSON: {"meta":"data","count":42} addProperty("count", 42) }, // Total extras size: 26 bytes (verified by gson.toJson().length) - operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, + operation = WireObjectOperation( + action = WireObjectOperationAction.MapCreate, objectId = "obj_54321", // Not counted in operation size // MapSet contributes to operation size - mapSet = MapSet( + mapSet = WireMapSet( key = "mapKey", // Size: 6 bytes (UTF-8 byte length) - value = ObjectData( + value = WireObjectData( objectId = "ref_obj", // Not counted in data size string = "sample" // Size: 6 bytes (UTF-8 byte length) ) // Total ObjectData size: 6 bytes ), // Total MapSet size: 6 + 6 = 12 bytes // CounterInc contributes to operation size - counterInc = CounterInc( + counterInc = WireCounterInc( number = 10.0 // Size: 8 bytes (number is always 8 bytes) ), // Total CounterInc size: 8 bytes // mapCreateWithObjectId.derivedFrom contributes to operation size (for client-initiated MAP_CREATE operations) - mapCreateWithObjectId = MapCreateWithObjectId( + mapCreateWithObjectId = WireMapCreateWithObjectId( nonce = "dummy-nonce", // Not counted in derivedFrom size initialValue = "{}", // Not counted in derivedFrom size - derivedFrom = MapCreate( - semantics = ObjectsMapSemantics.LWW, // Not counted in size + derivedFrom = WireMapCreate( + semantics = WireObjectsMapSemantics.LWW, // Not counted in size entries = mapOf( - "entry1" to ObjectsMapEntry( // Key size: 6 bytes + "entry1" to WireObjectsMapEntry( // Key size: 6 bytes tombstone = false, // Not counted in entry size timeserial = "ts_123", // Not counted in entry size - data = ObjectData( + data = WireObjectData( string = "value1" // Size: 6 bytes ) // ObjectMapEntry size: 6 bytes ), // Total for this entry: 6 (key) + 6 (entry) = 12 bytes - "entry2" to ObjectsMapEntry( // Key size: 6 bytes - data = ObjectData( + "entry2" to WireObjectsMapEntry( // Key size: 6 bytes + data = WireObjectData( number = 42.0 // Size: 8 bytes (number) ) // ObjectMapEntry size: 8 bytes ) // Total for this entry: 6 (key) + 8 (entry) = 14 bytes @@ -80,38 +85,38 @@ class ObjectMessageSizeTest { ), // Total mapCreateWithObjectId size (via derivedFrom): 26 bytes // counterCreateWithObjectId.derivedFrom contributes to operation size (for client-initiated COUNTER_CREATE operations) - counterCreateWithObjectId = CounterCreateWithObjectId( + counterCreateWithObjectId = WireCounterCreateWithObjectId( nonce = "dummy-nonce", // Not counted in derivedFrom size initialValue = "{}", // Not counted in derivedFrom size - derivedFrom = CounterCreate( + derivedFrom = WireCounterCreate( count = 100.0 // Size: 8 bytes (number is always 8 bytes) ), // Total derivedFrom (CounterCreate) size: 8 bytes ), // Total counterCreateWithObjectId size (via derivedFrom): 8 bytes ), // Total ObjectOperation size: 12 + 8 + 26 + 8 = 54 bytes - objectState = ObjectState( + objectState = WireObjectState( objectId = "state_obj", // Not counted in state size siteTimeserials = mapOf("site1" to "serial1"), // Not counted in state size tombstone = false, // Not counted in state size // createOp contributes to state size - createOp = ObjectOperation( - action = ObjectOperationAction.MapSet, + createOp = WireObjectOperation( + action = WireObjectOperationAction.MapSet, objectId = "create_obj", - mapSet = MapSet( + mapSet = WireMapSet( key = "createKey", // Size: 9 bytes - value = ObjectData( + value = WireObjectData( string = "createValue" // Size: 11 bytes ) // ObjectData size: 11 bytes ) // MapSet size: 9 + 11 = 20 bytes ), // Total createOp size: 20 bytes // map contributes to state size - map = ObjectsMap( + map = WireObjectsMap( entries = mapOf( - "stateKey" to ObjectsMapEntry( // Key size: 8 bytes - data = ObjectData( + "stateKey" to WireObjectsMapEntry( // Key size: 8 bytes + data = WireObjectData( string = "stateValue" // Size: 10 bytes ) // ObjectMapEntry size: 10 bytes ) // Total: 8 + 10 = 18 bytes @@ -119,7 +124,7 @@ class ObjectMessageSizeTest { ), // Total ObjectMap size: 18 bytes // counter contributes to state size - counter = ObjectsCounter( + counter = WireObjectsCounter( count = 50.0 // Size: 8 bytes ) // Total ObjectCounter size: 8 bytes ), // Total ObjectState size: 20 + 18 + 8 = 46 bytes @@ -138,13 +143,13 @@ class ObjectMessageSizeTest { @Test fun testObjectMessageSizeForUnicodeCharacters() = runTest { - val objectMessage = ObjectMessage( - operation = ObjectOperation( + val objectMessage = WireObjectMessage( + operation = WireObjectOperation( objectId = "", - action = ObjectOperationAction.MapSet, - mapSet = MapSet( + action = WireObjectOperationAction.MapSet, + mapSet = WireMapSet( key = "", - value = ObjectData( + value = WireObjectData( string = "你😊" // 你 -> 3 bytes, 😊 -> 4 bytes ), ), @@ -155,18 +160,18 @@ class ObjectMessageSizeTest { @Test fun testObjectMessageSizeAboveLimit() = runTest { - val mockAdapter = getMockObjectsAdapter() + val mockAdapter = getMockAblyClientAdapter() mockAdapter.connectionManager.maxMessageSize = Defaults.maxMessageSize // 64 kb assertEquals(65536, mockAdapter.connectionManager.maxMessageSize) // Create ObjectMessage with dummy data that results in size 60kb - val objectMessage1 = ObjectMessage( + val objectMessage1 = WireObjectMessage( clientId = CharArray(60 * 1024) { ('a'..'z').random() }.concatToString() ) assertEquals(60 * 1024, objectMessage1.size()) // Create ObjectMessage with dummy data that results in size 5kb - val objectMessage2 = ObjectMessage( + val objectMessage2 = WireObjectMessage( clientId = CharArray(5 * 1024) { ('a'..'z').random() }.concatToString() ) assertEquals(5 * 1024, objectMessage2.size()) diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/TestHelpers.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/TestHelpers.kt new file mode 100644 index 000000000..1ccfbc66e --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/TestHelpers.kt @@ -0,0 +1,49 @@ +package io.ably.lib.liveobjects.unit + +import io.ably.lib.liveobjects.adapter.AblyClientAdapter +import io.ably.lib.liveobjects.connectionManager +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.ChannelState +import io.ably.lib.transport.ConnectionManager +import io.ably.lib.types.ChannelMode +import io.ably.lib.types.ChannelOptions +import io.ably.lib.types.ClientOptions +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.spyk + +internal fun getMockRealtimeChannel( + channelName: String, + clientId: String = "client1", + channelModes: Array = arrayOf(ChannelMode.object_publish, ChannelMode.object_subscribe)): Channel { + val client = AblyRealtime(ClientOptions().apply { + autoConnect = false + key = "keyName:Value" + this.clientId = clientId + }) + val channelOpts = ChannelOptions().apply { modes = channelModes } + val channel = client.channels.get(channelName, channelOpts) + return spyk(channel) { + every { attach() } answers { + state = ChannelState.attached + } + every { detach() } answers { + state = ChannelState.detached + } + every { subscribe(any(), any()) } returns mockk(relaxUnitFun = true) + every { subscribe(any>(), any()) } returns mockk(relaxUnitFun = true) + every { subscribe(any()) } returns mockk(relaxUnitFun = true) + }.apply { + state = ChannelState.attached + } +} + +internal fun getMockAblyClientAdapter(): AblyClientAdapter { + mockkStatic("io.ably.lib.liveobjects.HelpersKt") + return mockk(relaxed = true) { + every { getChannel(any()) } returns getMockRealtimeChannel("testChannelName") + every { connectionManager } returns mockk(relaxed = true) + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/UtilsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/UtilsTest.kt new file mode 100644 index 000000000..169e72d9c --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/UtilsTest.kt @@ -0,0 +1,93 @@ +package io.ably.lib.liveobjects.unit + +import io.ably.lib.liveobjects.* +import io.ably.lib.liveobjects.ObjectErrorCode +import io.ably.lib.liveobjects.ObjectHttpStatusCode +import io.ably.lib.liveobjects.byteSize +import io.ably.lib.liveobjects.clientError +import io.ably.lib.liveobjects.generateNonce +import io.ably.lib.types.ErrorInfo +import org.junit.Test +import org.junit.Assert.* + +class UtilsTest { + + @Test + fun testGenerateNonce() { + // Test basic functionality + val nonce1 = generateNonce() + val nonce2 = generateNonce() + + assertEquals(16, nonce1.length) + assertEquals(16, nonce2.length) + assertNotEquals(nonce1, nonce2) // Should be random + + // Test character set + val validChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + val nonce = generateNonce() + nonce.forEach { char -> + assertTrue("Nonce should only contain valid characters", validChars.contains(char)) + } + } + + @Test + fun testStringByteSize() { + // Test ASCII strings + assertEquals(5, "Hello".byteSize) + assertEquals(0, "".byteSize) + assertEquals(1, "A".byteSize) + + // Test non-ASCII strings + assertEquals(3, "你".byteSize) // Chinese character + assertEquals(4, "😊".byteSize) // Emoji + assertEquals(6, "你好".byteSize) // Two Chinese characters + } + + @Test + fun testErrorCreationFunctions() { + // Test clientError + val clientEx = clientError("Bad request") + assertEquals("Bad request", clientEx.errorInfo.message) + assertEquals(ObjectErrorCode.BadRequest.code, clientEx.errorInfo.code) + assertEquals(ObjectHttpStatusCode.BadRequest.code, clientEx.errorInfo.statusCode) + + // Test serverError + val serverEx = serverError("Internal error") + assertEquals("Internal error", serverEx.errorInfo.message) + assertEquals(ObjectErrorCode.InternalError.code, serverEx.errorInfo.code) + assertEquals(ObjectHttpStatusCode.InternalServerError.code, serverEx.errorInfo.statusCode) + + // Test objectError + val objectEx = objectError("Invalid object") + assertEquals("Invalid object", objectEx.errorInfo.message) + assertEquals(ObjectErrorCode.InvalidObject.code, objectEx.errorInfo.code) + assertEquals(ObjectHttpStatusCode.InternalServerError.code, objectEx.errorInfo.statusCode) + + // Test objectError with cause + val cause = RuntimeException("Original error") + val objectExWithCause = objectError("Invalid object", cause) + assertEquals("Invalid object", objectExWithCause.errorInfo.message) + assertEquals(cause, objectExWithCause.cause) + } + + @Test + fun testAblyExceptionCreation() { + // Test with error message and codes + val ex = ablyException("Test error", ObjectErrorCode.BadRequest, ObjectHttpStatusCode.BadRequest) + assertEquals("Test error", ex.errorInfo.message) + assertEquals(ObjectErrorCode.BadRequest.code, ex.errorInfo.code) + assertEquals(ObjectHttpStatusCode.BadRequest.code, ex.errorInfo.statusCode) + + // Test with ErrorInfo + val errorInfo = ErrorInfo("Custom error", 400, 40000) + val ex2 = ablyException(errorInfo) + assertEquals("Custom error", ex2.errorInfo.message) + assertEquals(400, ex2.errorInfo.statusCode) + assertEquals(40000, ex2.errorInfo.code) + + // Test with cause + val cause = RuntimeException("Cause") + val ex3 = ablyException(errorInfo, cause) + assertEquals(cause, ex3.cause) + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/fixtures/ObjectMessageFixtures.kt similarity index 59% rename from liveobjects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/fixtures/ObjectMessageFixtures.kt index 6c2f60ccf..5fcccf56d 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/fixtures/ObjectMessageFixtures.kt @@ -1,58 +1,63 @@ -package io.ably.lib.objects.unit.fixtures +package io.ably.lib.liveobjects.unit.fixtures import com.google.gson.JsonArray import com.google.gson.JsonObject -import io.ably.lib.objects.* -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapCreateWithObjectId -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectState +import io.ably.lib.liveobjects.message.WireMapCreate +import io.ably.lib.liveobjects.message.WireMapCreateWithObjectId +import io.ably.lib.liveobjects.message.WireMapSet +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperation +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.message.WireObjectState +import io.ably.lib.liveobjects.message.WireObjectsCounter +import io.ably.lib.liveobjects.message.WireObjectsMap +import io.ably.lib.liveobjects.message.WireObjectsMapEntry +import io.ably.lib.liveobjects.message.WireObjectsMapSemantics import java.util.Base64 -internal val dummyObjectDataStringValue = ObjectData(objectId = "object-id", string = "dummy string") +internal val dummyObjectDataStringValue = WireObjectData(objectId = "object-id", string = "dummy string") -internal val dummyBinaryObjectValue = ObjectData(objectId = "object-id", bytes = Base64.getEncoder().encodeToString(byteArrayOf(1, 2, 3))) +internal val dummyBinaryObjectValue = WireObjectData(objectId = "object-id", bytes = Base64.getEncoder().encodeToString(byteArrayOf(1, 2, 3))) -internal val dummyNumberObjectValue = ObjectData(objectId = "object-id", number = 42.0) +internal val dummyNumberObjectValue = WireObjectData(objectId = "object-id", number = 42.0) -internal val dummyBooleanObjectValue = ObjectData(objectId = "object-id", boolean = true) +internal val dummyBooleanObjectValue = WireObjectData(objectId = "object-id", boolean = true) val dummyJsonObject = JsonObject().apply { addProperty("foo", "bar") } -internal val dummyJsonObjectValue = ObjectData(objectId = "object-id", json = dummyJsonObject) +internal val dummyJsonObjectValue = WireObjectData(objectId = "object-id", json = dummyJsonObject) val dummyJsonArray = JsonArray().apply { add(1); add(2); add(3) } -internal val dummyJsonArrayValue = ObjectData(objectId = "object-id", json = dummyJsonArray) +internal val dummyJsonArrayValue = WireObjectData(objectId = "object-id", json = dummyJsonArray) -internal val dummyObjectsMapEntry = ObjectsMapEntry( +internal val dummyObjectsMapEntry = WireObjectsMapEntry( tombstone = false, timeserial = "dummy-timeserial", data = dummyObjectDataStringValue ) -internal val dummyObjectsMap = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, +internal val dummyObjectsMap = WireObjectsMap( + semantics = WireObjectsMapSemantics.LWW, entries = mapOf("dummy-key" to dummyObjectsMapEntry) ) -internal val dummyObjectsCounter = ObjectsCounter( +internal val dummyObjectsCounter = WireObjectsCounter( count = 123.0 ) -internal val dummyMapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, +internal val dummyMapCreate = WireMapCreate( + semantics = WireObjectsMapSemantics.LWW, entries = mapOf("dummy-key" to dummyObjectsMapEntry) ) -internal val dummyObjectOperation = ObjectOperation( - action = ObjectOperationAction.MapCreate, +internal val dummyObjectOperation = WireObjectOperation( + action = WireObjectOperationAction.MapCreate, objectId = "dummy-object-id", mapCreate = dummyMapCreate, - mapCreateWithObjectId = MapCreateWithObjectId(nonce = "dummy-nonce", initialValue = "{\"foo\":\"bar\"}"), + mapCreateWithObjectId = WireMapCreateWithObjectId(nonce = "dummy-nonce", initialValue = "{\"foo\":\"bar\"}"), ) -internal val dummyObjectState = ObjectState( +internal val dummyObjectState = WireObjectState( objectId = "dummy-object-id", siteTimeserials = mapOf("site1" to "serial1"), tombstone = false, @@ -61,7 +66,7 @@ internal val dummyObjectState = ObjectState( counter = dummyObjectsCounter ) -internal val dummyObjectMessage = ObjectMessage( +internal val dummyObjectMessage = WireObjectMessage( id = "dummy-id", timestamp = 1234567890L, clientId = "dummy-client-id", @@ -73,11 +78,11 @@ internal val dummyObjectMessage = ObjectMessage( siteCode = "dummy-site-code" ) -internal fun dummyObjectMessageWithStringData(): ObjectMessage { +internal fun dummyObjectMessageWithStringData(): WireObjectMessage { return dummyObjectMessage } -internal fun dummyObjectMessageWithBinaryData(): ObjectMessage { +internal fun dummyObjectMessageWithBinaryData(): WireObjectMessage { val binaryObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyBinaryObjectValue) val binaryObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to binaryObjectMapEntry)) val binaryMapCreate = dummyMapCreate.copy(entries = mapOf("dummy-key" to binaryObjectMapEntry)) @@ -92,7 +97,7 @@ internal fun dummyObjectMessageWithBinaryData(): ObjectMessage { ) } -internal fun dummyObjectMessageWithNumberData(): ObjectMessage { +internal fun dummyObjectMessageWithNumberData(): WireObjectMessage { val numberObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyNumberObjectValue) val numberObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to numberObjectMapEntry)) val numberMapCreate = dummyMapCreate.copy(entries = mapOf("dummy-key" to numberObjectMapEntry)) @@ -107,7 +112,7 @@ internal fun dummyObjectMessageWithNumberData(): ObjectMessage { ) } -internal fun dummyObjectMessageWithBooleanData(): ObjectMessage { +internal fun dummyObjectMessageWithBooleanData(): WireObjectMessage { val booleanObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyBooleanObjectValue) val booleanObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to booleanObjectMapEntry)) val booleanMapCreate = dummyMapCreate.copy(entries = mapOf("dummy-key" to booleanObjectMapEntry)) @@ -122,14 +127,14 @@ internal fun dummyObjectMessageWithBooleanData(): ObjectMessage { ) } -internal fun dummyObjectMessageWithJsonObjectData(): ObjectMessage { +internal fun dummyObjectMessageWithJsonObjectData(): WireObjectMessage { val jsonObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyJsonObjectValue) val jsonObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to jsonObjectMapEntry)) val jsonMapCreate = dummyMapCreate.copy(entries = mapOf("dummy-key" to jsonObjectMapEntry)) val jsonObjectOperation = dummyObjectOperation.copy( - action = ObjectOperationAction.MapSet, + action = WireObjectOperationAction.MapSet, mapCreate = null, - mapSet = MapSet(key = "dummy-key", value = dummyJsonObjectValue) + mapSet = WireMapSet(key = "dummy-key", value = dummyJsonObjectValue) ) val jsonObjectState = dummyObjectState.copy( map = jsonObjectMap, @@ -141,13 +146,13 @@ internal fun dummyObjectMessageWithJsonObjectData(): ObjectMessage { ) } -internal fun dummyObjectMessageWithJsonArrayData(): ObjectMessage { +internal fun dummyObjectMessageWithJsonArrayData(): WireObjectMessage { val jsonArrayMapEntry = dummyObjectsMapEntry.copy(data = dummyJsonArrayValue) val jsonArrayMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to jsonArrayMapEntry)) val jsonArrayOperation = dummyObjectOperation.copy( - action = ObjectOperationAction.MapSet, + action = WireObjectOperationAction.MapSet, mapCreate = null, - mapSet = MapSet(key = "dummy-key", value = dummyJsonArrayValue) + mapSet = WireMapSet(key = "dummy-key", value = dummyJsonArrayValue) ) val jsonArrayState = dummyObjectState.copy( map = jsonArrayMap, diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt deleted file mode 100644 index 79a99de32..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt +++ /dev/null @@ -1,367 +0,0 @@ -package io.ably.lib.objects.integration - -import io.ably.lib.objects.assertWaiter -import io.ably.lib.objects.integration.helpers.ObjectId -import io.ably.lib.objects.integration.helpers.fixtures.createUserEngagementMatrixMap -import io.ably.lib.objects.integration.helpers.fixtures.createUserMapWithCountersObject -import io.ably.lib.objects.integration.setup.IntegrationTest -import io.ably.lib.objects.type.map.LiveMapValue -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class DefaultLiveCounterTest: IntegrationTest() { - /** - * Tests the synchronization process when a user map object with counters is initialized before channel attach. - * This includes checking the initial values of all counter objects and nested maps in the - * comprehensive user engagement counter structure. - */ - @Test - fun testLiveCounterSync() = runTest { - val channelName = generateChannelName() - val userMapObjectId = restObjects.createUserMapWithCountersObject(channelName) - restObjects.setMapRef(channelName, "root", "user", userMapObjectId) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - // Get the user map object from the root map - val userMap = rootMap.get("user")?.asLiveMap - assertNotNull(userMap, "User map should be synchronized") - assertEquals(7L, userMap.size(), "User map should contain 7 top-level entries") - - // Assert direct counter objects at the top level of the user map - // Test profileViews counter - should have initial value of 127 - val profileViewsCounter = userMap.get("profileViews")?.asLiveCounter - assertNotNull(profileViewsCounter, "Profile views counter should exist") - assertEquals(127.0, profileViewsCounter.value(), "Profile views counter should have initial value of 127") - - // Test postLikes counter - should have initial value of 45 - val postLikesCounter = userMap.get("postLikes")?.asLiveCounter - assertNotNull(postLikesCounter, "Post likes counter should exist") - assertEquals(45.0, postLikesCounter.value(), "Post likes counter should have initial value of 45") - - // Test commentCount counter - should have initial value of 23 - val commentCountCounter = userMap.get("commentCount")?.asLiveCounter - assertNotNull(commentCountCounter, "Comment count counter should exist") - assertEquals(23.0, commentCountCounter.value(), "Comment count counter should have initial value of 23") - - // Test followingCount counter - should have initial value of 89 - val followingCountCounter = userMap.get("followingCount")?.asLiveCounter - assertNotNull(followingCountCounter, "Following count counter should exist") - assertEquals(89.0, followingCountCounter.value(), "Following count counter should have initial value of 89") - - // Test followersCount counter - should have initial value of 156 - val followersCountCounter = userMap.get("followersCount")?.asLiveCounter - assertNotNull(followersCountCounter, "Followers count counter should exist") - assertEquals(156.0, followersCountCounter.value(), "Followers count counter should have initial value of 156") - - // Test loginStreak counter - should have initial value of 7 - val loginStreakCounter = userMap.get("loginStreak")?.asLiveCounter - assertNotNull(loginStreakCounter, "Login streak counter should exist") - assertEquals(7.0, loginStreakCounter.value(), "Login streak counter should have initial value of 7") - - // Assert the nested engagement metrics map - val engagementMetrics = userMap.get("engagementMetrics")?.asLiveMap - assertNotNull(engagementMetrics, "Engagement metrics map should exist") - assertEquals(4L, engagementMetrics.size(), "Engagement metrics map should contain 4 counter entries") - - // Assert counter objects within the engagement metrics map - // Test totalShares counter - should have initial value of 34 - val totalSharesCounter = engagementMetrics.get("totalShares")?.asLiveCounter - assertNotNull(totalSharesCounter, "Total shares counter should exist") - assertEquals(34.0, totalSharesCounter.value(), "Total shares counter should have initial value of 34") - - // Test totalBookmarks counter - should have initial value of 67 - val totalBookmarksCounter = engagementMetrics.get("totalBookmarks")?.asLiveCounter - assertNotNull(totalBookmarksCounter, "Total bookmarks counter should exist") - assertEquals(67.0, totalBookmarksCounter.value(), "Total bookmarks counter should have initial value of 67") - - // Test totalReactions counter - should have initial value of 189 - val totalReactionsCounter = engagementMetrics.get("totalReactions")?.asLiveCounter - assertNotNull(totalReactionsCounter, "Total reactions counter should exist") - assertEquals(189.0, totalReactionsCounter.value(), "Total reactions counter should have initial value of 189") - - // Test dailyActiveStreak counter - should have initial value of 12 - val dailyActiveStreakCounter = engagementMetrics.get("dailyActiveStreak")?.asLiveCounter - assertNotNull(dailyActiveStreakCounter, "Daily active streak counter should exist") - assertEquals(12.0, dailyActiveStreakCounter.value(), "Daily active streak counter should have initial value of 12") - - // Verify that all expected counter keys exist at the top level - val topLevelKeys = userMap.keys().toSet() - val expectedTopLevelKeys = setOf( - "profileViews", "postLikes", "commentCount", "followingCount", - "followersCount", "loginStreak", "engagementMetrics" - ) - assertEquals(expectedTopLevelKeys, topLevelKeys, "Top-level keys should match expected counter keys") - - // Verify that all expected counter keys exist in the engagement metrics map - val engagementKeys = engagementMetrics.keys().toSet() - val expectedEngagementKeys = setOf( - "totalShares", "totalBookmarks", "totalReactions", "dailyActiveStreak" - ) - assertEquals(expectedEngagementKeys, engagementKeys, "Engagement metrics keys should match expected counter keys") - - // Verify total counter values match expectations (useful for integration testing) - val totalUserCounterValues = listOf(127.0, 45.0, 23.0, 89.0, 156.0, 7.0).sum() - val totalEngagementCounterValues = listOf(34.0, 67.0, 189.0, 12.0).sum() - assertEquals(447.0, totalUserCounterValues, "Sum of user counter values should be 447") - assertEquals(302.0, totalEngagementCounterValues, "Sum of engagement counter values should be 302") - } - - /** - * Tests sequential counter operations including creation with initial value, incrementing by various amounts, - * decrementing by various amounts, and validates the resulting counter value after each operation. - */ - @Test - fun testLiveCounterOperations() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - // Step 1: Create a new counter with initial value of 10 - val testCounterObjectId = restObjects.createCounter(channelName, initialValue = 10.0) - restObjects.setMapRef(channelName, "root", "testCounter", testCounterObjectId) - - // Wait for updated testCounter to be available in the root map - assertWaiter { rootMap.get("testCounter") != null } - - // Assert initial state after creation - val testCounter = rootMap.get("testCounter")?.asLiveCounter - assertNotNull(testCounter, "Test counter should be created and accessible") - assertEquals(10.0, testCounter.value(), "Counter should have initial value of 10") - - // Step 2: Increment counter by 5 (10 + 5 = 15) - restObjects.incrementCounter(channelName, testCounterObjectId, 5.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 15.0 } - - // Assert after first increment - assertEquals(15.0, testCounter.value(), "Counter should be incremented to 15") - - // Step 3: Increment counter by 3 (15 + 3 = 18) - restObjects.incrementCounter(channelName, testCounterObjectId, 3.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 18.0 } - - // Assert after second increment - assertEquals(18.0, testCounter.value(), "Counter should be incremented to 18") - - // Step 4: Increment counter by a larger amount: 12 (18 + 12 = 30) - restObjects.incrementCounter(channelName, testCounterObjectId, 12.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 30.0 } - - // Assert after third increment - assertEquals(30.0, testCounter.value(), "Counter should be incremented to 30") - - // Step 5: Decrement counter by 7 (30 - 7 = 23) - restObjects.decrementCounter(channelName, testCounterObjectId, 7.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 23.0 } - - // Assert after first decrement - assertEquals(23.0, testCounter.value(), "Counter should be decremented to 23") - - // Step 6: Decrement counter by 4 (23 - 4 = 19) - restObjects.decrementCounter(channelName, testCounterObjectId, 4.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 19.0 } - - // Assert after second decrement - assertEquals(19.0, testCounter.value(), "Counter should be decremented to 19") - - // Step 7: Increment counter by 1 (19 + 1 = 20) - restObjects.incrementCounter(channelName, testCounterObjectId, 1.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 20.0 } - - // Assert after final increment - assertEquals(20.0, testCounter.value(), "Counter should be incremented to 20") - - // Step 8: Decrement counter by a larger amount: 15 (20 - 15 = 5) - restObjects.decrementCounter(channelName, testCounterObjectId, 15.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 5.0 } - - // Assert after large decrement - assertEquals(5.0, testCounter.value(), "Counter should be decremented to 5") - - // Final verification - test final increment to ensure counter still works - restObjects.incrementCounter(channelName, testCounterObjectId, 25.0) - assertWaiter { testCounter.value() == 30.0 } - - // Assert final state - assertEquals(30.0, testCounter.value(), "Counter should have final value of 30") - - // Verify the counter object is still accessible and functioning - assertNotNull(testCounter, "Counter should still be accessible at the end") - - // Verify we can still access it from the root map - val finalCounterCheck = rootMap.get("testCounter")?.asLiveCounter - assertNotNull(finalCounterCheck, "Counter should still be accessible from root map") - assertEquals(30.0, finalCounterCheck.value(), "Final counter value should be 30 when accessed from root map") - } - - @Test - fun testLiveCounterOperationsUsingRealtime() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val objects = channel.objects - val rootMap = channel.objects.root - - // Step 1: Create a new counter with initial value of 10 - val testCounterObject = objects.createCounter( 10.0) - rootMap.set("testCounter", LiveMapValue.of(testCounterObject)) - - // Wait for updated testCounter to be available in the root map - assertWaiter { rootMap.get("testCounter") != null } - - // Assert initial state after creation - val testCounter = rootMap.get("testCounter")?.asLiveCounter - assertNotNull(testCounter, "Test counter should be created and accessible") - assertEquals(10.0, testCounter.value(), "Counter should have initial value of 10") - - // Step 2: Increment counter by 5 (10 + 5 = 15) - testCounter.increment(5.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 15.0 } - - // Assert after first increment - assertEquals(15.0, testCounter.value(), "Counter should be incremented to 15") - - // Step 3: Increment counter by 3 (15 + 3 = 18) - testCounter.increment(3.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 18.0 } - - // Assert after second increment - assertEquals(18.0, testCounter.value(), "Counter should be incremented to 18") - - // Step 4: Increment counter by a larger amount: 12 (18 + 12 = 30) - testCounter.increment(12.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 30.0 } - - // Assert after third increment - assertEquals(30.0, testCounter.value(), "Counter should be incremented to 30") - - // Step 5: Decrement counter by 7 (30 - 7 = 23) - testCounter.decrement(7.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 23.0 } - - // Assert after first decrement - assertEquals(23.0, testCounter.value(), "Counter should be decremented to 23") - - // Step 6: Decrement counter by 4 (23 - 4 = 19) - testCounter.decrement(4.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 19.0 } - - // Assert after second decrement - assertEquals(19.0, testCounter.value(), "Counter should be decremented to 19") - - // Step 7: Increment counter by 1 (19 + 1 = 20) - testCounter.increment(1.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 20.0 } - - // Assert after final increment - assertEquals(20.0, testCounter.value(), "Counter should be incremented to 20") - - // Step 8: Decrement counter by a larger amount: 15 (20 - 15 = 5) - testCounter.decrement(15.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 5.0 } - - // Assert after large decrement - assertEquals(5.0, testCounter.value(), "Counter should be decremented to 5") - - // Final verification - test final increment to ensure counter still works - testCounter.increment(25.0) - assertWaiter { testCounter.value() == 30.0 } - - // Assert final state - assertEquals(30.0, testCounter.value(), "Counter should have final value of 30") - - // Verify the counter object is still accessible and functioning - assertNotNull(testCounter, "Counter should still be accessible at the end") - - // Verify we can still access it from the root map - val finalCounterCheck = rootMap.get("testCounter")?.asLiveCounter - assertNotNull(finalCounterCheck, "Counter should still be accessible from root map") - assertEquals(30.0, finalCounterCheck.value(), "Final counter value should be 30 when accessed from root map") - } - - @Test - fun testLiveCounterChangesUsingSubscription() = runTest { - val channelName = generateChannelName() - val userEngagementMapId = restObjects.createUserEngagementMatrixMap(channelName) - restObjects.setMapRef(channelName, "root", "userMatrix", userEngagementMapId) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - val userEngagementMap = rootMap.get("userMatrix")?.asLiveMap - assertEquals(4L, userEngagementMap!!.size(), "User engagement map should contain 4 top-level entries") - - val totalReactions = userEngagementMap.get("totalReactions")?.asLiveCounter - assertEquals(189.0, totalReactions!!.value(), "Total reactions counter should have initial value of 189") - - // Subscribe to changes on the totalReactions counter - val counterUpdates = mutableListOf() - val totalReactionsSubscription = totalReactions.subscribe { update -> - counterUpdates.add(update.update.amount) - } - - // Step 1: Increment the totalReactions counter by 10 (189 + 10 = 199) - restObjects.incrementCounter(channelName, totalReactions.ObjectId, 10.0) - - // Wait for the update to be received - assertWaiter { counterUpdates.isNotEmpty() } - - // Verify the increment update was received - assertEquals(1, counterUpdates.size, "Should receive one update for increment") - assertEquals(10.0, counterUpdates.first(), "Update should contain increment amount of 10") - assertEquals(199.0, totalReactions.value(), "Counter should be incremented to 199") - - // Step 2: Decrement the totalReactions counter by 5 (199 - 5 = 194) - counterUpdates.clear() - restObjects.decrementCounter(channelName, totalReactions.ObjectId, 5.0) - - // Wait for the second update - assertWaiter { counterUpdates.isNotEmpty() } - - // Verify the decrement update was received - assertEquals(1, counterUpdates.size, "Should receive one update for decrement") - assertEquals(-5.0, counterUpdates.first(), "Update should contain decrement amount of -5") - assertEquals(194.0, totalReactions.value(), "Counter should be decremented to 194") - - // Step 3: Increment the totalReactions counter by 15 (194 + 15 = 209) - counterUpdates.clear() - restObjects.incrementCounter(channelName, totalReactions.ObjectId, 15.0) - - // Wait for the third update - assertWaiter { counterUpdates.isNotEmpty() } - - // Verify the third increment update was received - assertEquals(1, counterUpdates.size, "Should receive one update for third increment") - assertEquals(15.0, counterUpdates.first(), "Update should contain increment amount of 15") - assertEquals(209.0, totalReactions.value(), "Counter should be incremented to 209") - - // Clean up subscription - counterUpdates.clear() - totalReactionsSubscription.unsubscribe() - - // No updates should be received after unsubscribing - restObjects.incrementCounter(channelName, totalReactions.ObjectId, 20.0) - - // Wait for a moment to ensure no updates are received - assertWaiter { totalReactions.value() == 229.0 } - - assertTrue(counterUpdates.isEmpty(), "No updates should be received after unsubscribing") - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt deleted file mode 100644 index 0f2abb567..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt +++ /dev/null @@ -1,423 +0,0 @@ -package io.ably.lib.objects.integration - -import io.ably.lib.objects.* -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.integration.helpers.fixtures.createUserMapObject -import io.ably.lib.objects.integration.helpers.fixtures.createUserProfileMapObject -import io.ably.lib.objects.integration.setup.IntegrationTest -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.objects.type.map.LiveMapValue -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class DefaultLiveMapTest: IntegrationTest() { - /** - * Tests the synchronization process when a user map object is initialized before channel attach. - * This includes checking the initial values of all nested maps, counters, and primitive data types - * in the comprehensive user map object structure. - */ - @Test - fun testLiveMapSync() = runTest { - val channelName = generateChannelName() - val userMapObjectId = restObjects.createUserMapObject(channelName) - restObjects.setMapRef(channelName, "root", "user", userMapObjectId) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - // Get the user map object from the root map - val userMap = rootMap.get("user")?.asLiveMap - assertNotNull(userMap, "User map should be synchronized") - assertEquals(5L, userMap.size(), "User map should contain 5 top-level entries") - - // Assert Counter Objects - // Test loginCounter - should have initial value of 5 - val loginCounter = userMap.get("loginCounter")?.asLiveCounter - assertNotNull(loginCounter, "Login counter should exist") - assertEquals(5.0, loginCounter.value(), "Login counter should have initial value of 5") - - // Test sessionCounter - should have initial value of 0 - val sessionCounter = userMap.get("sessionCounter")?.asLiveCounter - assertNotNull(sessionCounter, "Session counter should exist") - assertEquals(0.0, sessionCounter.value(), "Session counter should have initial value of 0") - - // Assert User Profile Map - val userProfile = userMap.get("userProfile")?.asLiveMap - assertNotNull(userProfile, "User profile map should exist") - assertEquals(6L, userProfile.size(), "User profile should contain 6 entries") - - // Assert user profile primitive values - assertEquals("user123", userProfile.get("userId")?.asString, "User ID should match expected value") - assertEquals("John Doe", userProfile.get("name")?.asString, "User name should match expected value") - assertEquals("john@example.com", userProfile.get("email")?.asString, "User email should match expected value") - assertEquals(true, userProfile.get("isActive")?.asBoolean, "User should be active") - - // Assert Preferences Map (nested within user profile) - val preferences = userProfile.get("preferences")?.asLiveMap - assertNotNull(preferences, "Preferences map should exist") - assertEquals(4L, preferences.size(), "Preferences should contain 4 entries") - assertEquals("dark", preferences.get("theme")?.asString, "Theme preference should be dark") - assertEquals(true, preferences.get("notifications")?.asBoolean, "Notifications should be enabled") - assertEquals("en", preferences.get("language")?.asString, "Language should be English") - assertEquals(3.0, preferences.get("maxRetries")?.asNumber, "Max retries should be 3") - - // Assert Metrics Map (nested within user profile) - val metrics = userProfile.get("metrics")?.asLiveMap - assertNotNull(metrics, "Metrics map should exist") - assertEquals(4L, metrics.size(), "Metrics should contain 4 entries") - assertEquals("2024-01-01T08:30:00Z", metrics.get("lastLoginTime")?.asString, "Last login time should match") - assertEquals(42.0, metrics.get("profileViews")?.asNumber, "Profile views should be 42") - - // Test counter references within metrics map - val totalLoginsCounter = metrics.get("totalLogins")?.asLiveCounter - assertNotNull(totalLoginsCounter, "Total logins counter should exist") - assertEquals(5.0, totalLoginsCounter.value(), "Total logins should reference login counter with value 5") - - val activeSessionsCounter = metrics.get("activeSessions")?.asLiveCounter - assertNotNull(activeSessionsCounter, "Active sessions counter should exist") - assertEquals(0.0, activeSessionsCounter.value(), "Active sessions should reference session counter with value 0") - - // Assert direct references to maps from top-level user map - val preferencesMapRef = userMap.get("preferencesMap")?.asLiveMap - assertNotNull(preferencesMapRef, "Preferences map reference should exist") - assertEquals(4L, preferencesMapRef.size(), "Referenced preferences map should have 4 entries") - assertEquals("dark", preferencesMapRef.get("theme")?.asString, "Referenced preferences should match nested preferences") - - val metricsMapRef = userMap.get("metricsMap")?.asLiveMap - assertNotNull(metricsMapRef, "Metrics map reference should exist") - assertEquals(4L, metricsMapRef.size(), "Referenced metrics map should have 4 entries") - assertEquals("2024-01-01T08:30:00Z", metricsMapRef.get("lastLoginTime")?.asString, "Referenced metrics should match nested metrics") - - // Verify that references point to the same objects - assertEquals(preferences.get("theme")?.asString, preferencesMapRef.get("theme")?.asString, "Preference references should point to same data") - assertEquals(metrics.get("profileViews")?.asNumber, metricsMapRef.get("profileViews")?.asNumber, "Metrics references should point to same data") - } - - /** - * Tests sequential map operations including creation with initial data, updating existing fields, - * adding new fields, and removing fields. Validates the resulting data after each operation. - */ - @Test - fun testLiveMapOperations() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - // Step 1: Create a new map with initial data - val testMapObjectId = restObjects.createMap( - channelName, - data = mapOf( - "name" to ObjectData(string = "Alice"), - "age" to ObjectData(number = 30.0), - "isActive" to ObjectData(boolean = true) - ) - ) - restObjects.setMapRef(channelName, "root", "testMap", testMapObjectId) - - // wait for updated testMap to be available in the root map - assertWaiter { rootMap.get("testMap") != null } - - // Assert initial state after creation - val testMap = rootMap.get("testMap")?.asLiveMap - assertNotNull(testMap, "Test map should be created and accessible") - assertEquals(3L, testMap.size(), "Test map should have 3 initial entries") - assertEquals("Alice", testMap.get("name")?.asString, "Initial name should be Alice") - assertEquals(30.0, testMap.get("age")?.asNumber, "Initial age should be 30") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Initial active status should be true") - - // Step 2: Update an existing field (name from "Alice" to "Bob") - restObjects.setMapValue(channelName, testMapObjectId, "name", ObjectData(string = "Bob")) - // Wait for the map to be updated - assertWaiter { testMap.get("name")?.asString == "Bob" } - - // Assert after updating existing field - assertEquals(3L, testMap.size(), "Map size should remain the same after update") - assertEquals("Bob", testMap.get("name")?.asString, "Name should be updated to Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - - // Step 3: Add a new field (email) - restObjects.setMapValue(channelName, testMapObjectId, "email", ObjectData(string = "bob@example.com")) - // Wait for the map to be updated - assertWaiter { testMap.get("email")?.asString == "bob@example.com" } - - // Assert after adding new field - assertEquals(4L, testMap.size(), "Map size should increase after adding new field") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should be added successfully") - - // Step 4: Add another new field with different data type (score as number) - restObjects.setMapValue(channelName, testMapObjectId, "score", ObjectData(number = 85.0)) - // Wait for the map to be updated - assertWaiter { testMap.get("score")?.asNumber == 85.0 } - - // Assert after adding second new field - assertEquals(5L, testMap.size(), "Map size should increase to 5 after adding score") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should be added as numeric value") - - // Step 5: Update the boolean field - restObjects.setMapValue(channelName, testMapObjectId, "isActive", ObjectData(boolean = false)) - // Wait for the map to be updated - assertWaiter { testMap.get("isActive")?.asBoolean == false } - - // Assert after updating boolean field - assertEquals(5L, testMap.size(), "Map size should remain 5 after boolean update") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should be updated to false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") - - // Step 6: Remove a field (age) - restObjects.removeMapValue(channelName, testMapObjectId, "age") - // Wait for the map to be updated - assertWaiter { testMap.get("age") == null } - - // Assert after removing field - assertEquals(4L, testMap.size(), "Map size should decrease to 4 after removing age") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertNull(testMap.get("age"), "Age should be removed and return null") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") - - // Step 7: Remove another field (score) - restObjects.removeMapValue(channelName, testMapObjectId, "score") - // Wait for the map to be updated - assertWaiter { testMap.get("score") == null } - - // Assert final state after second removal - assertEquals(3L, testMap.size(), "Map size should decrease to 3 after removing score") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertNull(testMap.get("score"), "Score should be removed and return null") - assertNull(testMap.get("age"), "Age should remain null") - - // Final verification - ensure all expected keys exist and unwanted keys don't - assertEquals(3, testMap.size(), "Final map should have exactly 3 entries") - - val finalKeys = testMap.keys().toSet() - assertEquals(setOf("name", "isActive", "email"), finalKeys, "Final keys should match expected set") - - val finalValues = testMap.values().map { it.value }.toSet() - assertEquals(setOf("Bob", false, "bob@example.com"), finalValues, "Final string values should match expected set") - } - - /** - * Tests sequential map operations including creation with initial data, updating existing fields, - * adding new fields, and removing fields. Validates the resulting data after each operation. - */ - @Test - fun testLiveMapOperationsUsingRealtime() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val objects = channel.objects - val rootMap = channel.objects.root - - // Step 1: Create a new map with initial data - val testMapObject = objects.createMap( - mapOf( - "name" to LiveMapValue.of("Alice"), - "age" to LiveMapValue.of(30), - "isActive" to LiveMapValue.of(true), - ) - ) - rootMap.set("testMap", LiveMapValue.of(testMapObject)) - - // wait for updated testMap to be available in the root map - assertWaiter { rootMap.get("testMap") != null } - - // Assert initial state after creation - val testMap = rootMap.get("testMap")?.asLiveMap - assertNotNull(testMap, "Test map should be created and accessible") - assertEquals(3L, testMap.size(), "Test map should have 3 initial entries") - assertEquals("Alice", testMap.get("name")?.asString, "Initial name should be Alice") - assertEquals(30.0, testMap.get("age")?.asNumber, "Initial age should be 30") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Initial active status should be true") - - // Step 2: Update an existing field (name from "Alice" to "Bob") - testMap.set("name", LiveMapValue.of("Bob")) - // Wait for the map to be updated - assertWaiter { testMap.get("name")?.asString == "Bob" } - - // Assert after updating existing field - assertEquals(3L, testMap.size(), "Map size should remain the same after update") - assertEquals("Bob", testMap.get("name")?.asString, "Name should be updated to Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - - // Step 3: Add a new field (email) - testMap.set("email", LiveMapValue.of("bob@example.com")) - // Wait for the map to be updated - assertWaiter { testMap.get("email")?.asString == "bob@example.com" } - - // Assert after adding new field - assertEquals(4L, testMap.size(), "Map size should increase after adding new field") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should be added successfully") - - // Step 4: Add another new field with different data type (score as number) - testMap.set("score", LiveMapValue.of(85)) - // Wait for the map to be updated - assertWaiter { testMap.get("score")?.asNumber == 85.0 } - - // Assert after adding second new field - assertEquals(5L, testMap.size(), "Map size should increase to 5 after adding score") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should be added as numeric value") - - // Step 5: Update the boolean field - testMap.set("isActive", LiveMapValue.of(false)) - // Wait for the map to be updated - assertWaiter { testMap.get("isActive")?.asBoolean == false } - - // Assert after updating boolean field - assertEquals(5L, testMap.size(), "Map size should remain 5 after boolean update") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should be updated to false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") - - // Step 6: Remove a field (age) - testMap.remove("age") - // Wait for the map to be updated - assertWaiter { testMap.get("age") == null } - - // Assert after removing field - assertEquals(4L, testMap.size(), "Map size should decrease to 4 after removing age") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertNull(testMap.get("age"), "Age should be removed and return null") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") - - // Step 7: Remove another field (score) - testMap.remove("score") - // Wait for the map to be updated - assertWaiter { testMap.get("score") == null } - - // Assert final state after second removal - assertEquals(3L, testMap.size(), "Map size should decrease to 3 after removing score") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertNull(testMap.get("score"), "Score should be removed and return null") - assertNull(testMap.get("age"), "Age should remain null") - - // Final verification - ensure all expected keys exist and unwanted keys don't - assertEquals(3, testMap.size(), "Final map should have exactly 3 entries") - - val finalKeys = testMap.keys().toSet() - assertEquals(setOf("name", "isActive", "email"), finalKeys, "Final keys should match expected set") - - val finalValues = testMap.values().map { it.value }.toSet() - assertEquals(setOf("Bob", false, "bob@example.com"), finalValues, "Final string values should match expected set") - } - - @Test - fun testLiveMapChangesUsingSubscription() = runTest { - val channelName = generateChannelName() - val userProfileObjectId = restObjects.createUserProfileMapObject(channelName) - restObjects.setMapRef(channelName, "root", "userProfile", userProfileObjectId) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - // Get the user profile map object from the root map - val userProfile = rootMap.get("userProfile")?.asLiveMap - assertNotNull(userProfile, "User profile should be synchronized") - assertEquals(4L, userProfile.size(), "User profile should contain 4 entries") - - // Verify initial values - assertEquals("user123", userProfile.get("userId")?.asString, "Initial userId should be user123") - assertEquals("John Doe", userProfile.get("name")?.asString, "Initial name should be John Doe") - assertEquals("john@example.com", userProfile.get("email")?.asString, "Initial email should be john@example.com") - assertEquals(true, userProfile.get("isActive")?.asBoolean, "Initial isActive should be true") - - // Subscribe to changes in the user profile map - val userProfileUpdates = mutableListOf() - val userProfileSubscription = userProfile.subscribe { update -> userProfileUpdates.add(update) } - - // Step 1: Update an existing field in the user profile map (change the name) - restObjects.setMapValue(channelName, userProfileObjectId, "name", ObjectData(string = "Bob Smith")) - - // Wait for the update to be received - assertWaiter { userProfileUpdates.isNotEmpty() } - - // Verify the update was received - assertEquals(1, userProfileUpdates.size, "Should receive one update") - val firstUpdateMap = userProfileUpdates.first().update - assertEquals(1, firstUpdateMap.size, "Should have one key change") - assertTrue(firstUpdateMap.containsKey("name"), "Update should contain name key") - assertEquals(LiveMapUpdate.Change.UPDATED, firstUpdateMap["name"], "name should be marked as UPDATED") - - // Verify the value was actually updated - assertEquals("Bob Smith", userProfile.get("name")?.asString, "Name should be updated to Bob Smith") - - // Step 2: Update another field in the user profile map (change the email) - userProfileUpdates.clear() - restObjects.setMapValue(channelName, userProfileObjectId, "email", ObjectData(string = "bob@example.com")) - - // Wait for the second update - assertWaiter { userProfileUpdates.isNotEmpty() } - - // Verify the second update - assertEquals(1, userProfileUpdates.size, "Should receive one update for the second change") - val secondUpdateMap = userProfileUpdates.first().update - assertEquals(1, secondUpdateMap.size, "Should have one key change") - assertTrue(secondUpdateMap.containsKey("email"), "Update should contain email key") - assertEquals(LiveMapUpdate.Change.UPDATED, secondUpdateMap["email"], "email should be marked as UPDATED") - - // Verify the value was actually updated - assertEquals("bob@example.com", userProfile.get("email")?.asString, "Email should be updated to bob@example.com") - - // Step 3: Remove an existing field from the user profile map (remove isActive) - userProfileUpdates.clear() - restObjects.removeMapValue(channelName, userProfileObjectId, "isActive") - - // Wait for the removal update - assertWaiter { userProfileUpdates.isNotEmpty() } - - // Verify the removal update - assertEquals(1, userProfileUpdates.size, "Should receive one update for removal") - val removalUpdateMap = userProfileUpdates.first().update - assertEquals(1, removalUpdateMap.size, "Should have one key change") - assertTrue(removalUpdateMap.containsKey("isActive"), "Update should contain isActive key") - assertEquals(LiveMapUpdate.Change.REMOVED, removalUpdateMap["isActive"], "isActive should be marked as REMOVED") - - // Verify final state of the user profile map - assertEquals(3L, userProfile.size(), "User profile should have 3 entries after removing isActive") - assertEquals("user123", userProfile.get("userId")?.asString, "userId should remain unchanged") - assertEquals("Bob Smith", userProfile.get("name")?.asString, "name should remain updated") - assertEquals("bob@example.com", userProfile.get("email")?.asString, "email should remain updated") - assertNull(userProfile.get("isActive"), "isActive should be removed") - - // Clean up subscription - userProfileUpdates.clear() - userProfileSubscription.unsubscribe() - // No updates should be received after unsubscribing - restObjects.setMapValue(channelName, userProfileObjectId, "country", ObjectData(string = "uk")) - - // Wait for a moment to ensure no updates are received - assertWaiter { userProfile.size() == 4L } - - assertTrue(userProfileUpdates.isEmpty(), "No updates should be received after unsubscribing") - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultRealtimeObjectsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultRealtimeObjectsTest.kt deleted file mode 100644 index 428fed56a..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultRealtimeObjectsTest.kt +++ /dev/null @@ -1,256 +0,0 @@ -package io.ably.lib.objects.integration - -import io.ably.lib.objects.* -import io.ably.lib.objects.integration.helpers.State -import io.ably.lib.objects.integration.helpers.fixtures.initializeRootMap -import io.ably.lib.objects.integration.helpers.simulateObjectDelete -import io.ably.lib.objects.integration.setup.IntegrationTest -import io.ably.lib.objects.state.ObjectsStateEvent -import io.ably.lib.objects.type.ObjectLifecycleEvent -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.type.map.LiveMapUpdate -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.text.toByteArray - -class DefaultRealtimeObjectsTest : IntegrationTest() { - - @Test - fun testChannelObjects() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val objects = channel.objects - assertNotNull(objects) - } - - @Test - fun testObjectsSyncEvents() = runTest { - val channelName = generateChannelName() - // Initialize the root map on the channel with initial data - restObjects.initializeRootMap(channelName) - - val channel = getRealtimeChannel(channelName) - val objects = channel.objects - assertNotNull(objects) - - assertEquals(ObjectsState.Initialized, objects.State, "Initial state should be INITIALIZED") - - val syncStates = mutableListOf() - objects.on(ObjectsStateEvent.SYNCING) { - syncStates.add(it) - } - objects.on(ObjectsStateEvent.SYNCED) { - syncStates.add(it) - } - - channel.attach() - - assertWaiter { syncStates.size == 2 } // Wait for both SYNCING and SYNCED events - - assertEquals(ObjectsStateEvent.SYNCING, syncStates[0], "First event should be SYNCING") - assertEquals(ObjectsStateEvent.SYNCED, syncStates[1], "Second event should be SYNCED") - - val rootMap = objects.root - assertEquals(6, rootMap.size(), "Root map should have 6 entries after sync") - } - - /** - * This will test objects sync process when the root map is initialized before channel attach. - * This includes checking the initial values of counters, maps, and other data types. - */ - @Test - fun testObjectsSync() = runTest { - val channelName = generateChannelName() - // Initialize the root map on the channel with initial data - restObjects.initializeRootMap(channelName) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - assertNotNull(rootMap) - - // Assert Counter Objects - // Test emptyCounter - should have initial value of 0 - val emptyCounter = rootMap.get("emptyCounter")?.asLiveCounter - assertNotNull(emptyCounter) - assertEquals(0.0, emptyCounter.value()) - - // Test initialValueCounter - should have initial value of 10 - val initialValueCounter = rootMap.get("initialValueCounter")?.asLiveCounter - assertNotNull(initialValueCounter) - assertEquals(10.0, initialValueCounter.value()) - - // Test referencedCounter - should have initial value of 20 - val referencedCounter = rootMap.get("referencedCounter")?.asLiveCounter - assertNotNull(referencedCounter) - assertEquals(20.0, referencedCounter.value()) - - // Assert Map Objects - // Test emptyMap - should be an empty map - val emptyMap = rootMap.get("emptyMap")?.asLiveMap - assertNotNull(emptyMap) - assertEquals(0L, emptyMap.size()) - - // Test referencedMap - should contain one key "counterKey" pointing to referencedCounter - val referencedMap = rootMap.get("referencedMap")?.asLiveMap - assertNotNull(referencedMap) - assertEquals(1L, referencedMap.size()) - val referencedMapCounter = referencedMap.get("counterKey")?.asLiveCounter - assertNotNull(referencedMapCounter) - assertEquals(20.0, referencedMapCounter.value()) // Should point to the same counter with value 20 - - // Test valuesMap - should contain all primitive data types and one map reference - val valuesMap = rootMap.get("valuesMap")?.asLiveMap - assertNotNull(valuesMap) - assertEquals(13L, valuesMap.size()) // Should have 13 entries - - // Assert string values - assertEquals("stringValue", valuesMap.get("string")?.asString) - assertEquals("", valuesMap.get("emptyString")?.asString) - - // Assert binary values - val bytesValue = valuesMap.get("bytes")?.asBinary - assertNotNull(bytesValue) - val expectedBinary = "eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9".toByteArray() - assertTrue(expectedBinary.contentEquals(bytesValue)) // Should contain encoded JSON data - - val emptyBytesValue = valuesMap.get("emptyBytes")?.asBinary - assertNotNull(emptyBytesValue) - assertEquals(0, emptyBytesValue.size) // Should be empty byte array - - // Assert numeric values - assertEquals(99999999.0, valuesMap.get("maxSafeNumber")?.asNumber) - assertEquals(-99999999.0, valuesMap.get("negativeMaxSafeNumber")?.asNumber) - assertEquals(1.0, valuesMap.get("number")?.asNumber) - assertEquals(0.0, valuesMap.get("zero")?.asNumber) - - // Assert boolean values - assertEquals(true, valuesMap.get("true")?.asBoolean) - assertEquals(false, valuesMap.get("false")?.asBoolean) - - // Assert JSON object value - should contain {"foo": "bar"} - val jsonObjectValue = valuesMap.get("object")?.asJsonObject - assertNotNull(jsonObjectValue) - assertEquals("bar", jsonObjectValue.get("foo").asString) - - // Assert JSON array value - should contain ["foo", "bar", "baz"] - val jsonArrayValue = valuesMap.get("array")?.asJsonArray - assertNotNull(jsonArrayValue) - assertEquals(3, jsonArrayValue.size()) - assertEquals("foo", jsonArrayValue[0].asString) - assertEquals("bar", jsonArrayValue[1].asString) - assertEquals("baz", jsonArrayValue[2].asString) - - // Assert map reference - should point to the same referencedMap - val mapRefValue = valuesMap.get("mapRef")?.asLiveMap - assertNotNull(mapRefValue) - assertEquals(1L, mapRefValue.size()) - val mapRefCounter = mapRefValue.get("counterKey")?.asLiveCounter - assertNotNull(mapRefCounter) - assertEquals(20.0, mapRefCounter.value()) // Should point to the same counter with value 20 - } - - /** - * Server runs periodic garbage collection (GC) to remove orphaned objects and will send - * OBJECT_DELETE events for objects that are no longer referenced. - * So, we simulate the deletion of an object by sending an object delete ProtocolMessage. - * This does not actually delete the object from the server, only simulates the deletion locally. - * Spec: RTLO4e - */ - @Test - fun testObjectDelete() = runTest { - val channelName = generateChannelName() - // Initialize the root map on the channel with initial data - restObjects.initializeRootMap(channelName) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - assertEquals(6L, rootMap.size()) // Should have 6 entries initially - - // Collection to track all lifecycle events - val lifecycleEvents = mutableListOf() - - // Remove the "referencedCounter" from the root map - val refCounter = rootMap.get("referencedCounter")?.asLiveCounter - assertNotNull(refCounter) - // Subscribe to counter updates to verify removal - val counterUpdates = mutableListOf() - refCounter.subscribe { event -> - counterUpdates.add(event.update.amount) - } - // Subscribe to lifecycle events for this counter - refCounter.on(ObjectLifecycleEvent.DELETED) { event -> - lifecycleEvents.add(event) - } - - // Simulate the deletion of the referencedCounter object - channel.objects.simulateObjectDelete(refCounter as DefaultLiveCounter) - - assertWaiter { rootMap.size() == 5L } // Wait for the removal to complete - assertNull(rootMap.get("referencedCounter")) // Should be null after removal - assertEquals(1, counterUpdates.size) // Should have received one update for deletion - assertEquals(-20.0, counterUpdates[0]) // The update should indicate counter was removed with value 20 - - // Remove the "referencedMap" from the root map - val referencedMap = rootMap.get("referencedMap")?.asLiveMap - assertNotNull(referencedMap) - // Subscribe to map updates to verify removal - val mapUpdates = mutableListOf>() - referencedMap.subscribe { event -> - mapUpdates.add(event.update) - } - // Subscribe to lifecycle events for this map - referencedMap.on(ObjectLifecycleEvent.DELETED) { event -> - lifecycleEvents.add(event) - } - - // Simulate the deletion of the referencedMap object - channel.objects.simulateObjectDelete(referencedMap as DefaultLiveMap) - - assertWaiter { rootMap.size() == 4L } // Wait for the removal to complete - assertNull(rootMap.get("referencedMap")) // Should be null after removal - assertEquals(1, mapUpdates.size) // Should have received one update for deletion - - val updatedMap = mapUpdates.first() - assertEquals(1, updatedMap.size) // Should have one change - assertEquals("counterKey", updatedMap.keys.first()) // The change should be for the "counterKey" - assertEquals(LiveMapUpdate.Change.REMOVED, updatedMap.values.first()) // Should indicate removal - - // Remove the "valuesMap" from the root map - val valuesMap = rootMap.get("valuesMap")?.asLiveMap - assertNotNull(valuesMap) - // Subscribe to map updates to verify removal - val valuesMapUpdates = mutableListOf>() - valuesMap.subscribe { event -> - valuesMapUpdates.add(event.update) - } - // Subscribe to lifecycle events for this map - valuesMap.on(ObjectLifecycleEvent.DELETED) { event -> - lifecycleEvents.add(event) - } - - // Simulate the deletion of the valuesMap object - channel.objects.simulateObjectDelete(valuesMap as DefaultLiveMap) - - assertWaiter { rootMap.size() == 3L } // Wait for the removal to complete - assertNull(rootMap.get("valuesMap")) // Should be null after removal - assertEquals(1, valuesMapUpdates.size) // Should have received one update for deletion - - val updatedValuesMap = valuesMapUpdates.first() - assertEquals(13, updatedValuesMap.size) // Should have 13 changes (one for each entry in valuesMap) - // Verify that all entries in valuesMap were marked as REMOVED - updatedValuesMap.values.forEach { change -> - assertEquals(LiveMapUpdate.Change.REMOVED, change) - } - - // Assert lifecycle events - assertEquals(3, lifecycleEvents.size) // Should have received 3 DELETED lifecycle events - lifecycleEvents.forEach { event -> - assertEquals(ObjectLifecycleEvent.DELETED, event) // All events should be DELETED - } - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt deleted file mode 100644 index 05b50b7dc..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt +++ /dev/null @@ -1,40 +0,0 @@ -package io.ably.lib.objects.integration.helpers - -import io.ably.lib.objects.* -import io.ably.lib.objects.DefaultRealtimeObjects -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.counter.LiveCounter -import io.ably.lib.objects.type.map.LiveMap -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.types.ProtocolMessage - -internal val LiveMap.ObjectId get() = (this as DefaultLiveMap).objectId - -internal val LiveCounter.ObjectId get() = (this as DefaultLiveCounter).objectId - -internal val RealtimeObjects.State get() = (this as DefaultRealtimeObjects).state - -/** - * Server runs periodic garbage collection (GC) to remove orphaned objects and will send - * OBJECT_DELETE events for objects that are no longer referenced. - * So, we simulate the deletion of an object by sending a ProtocolMessage. - */ -internal fun RealtimeObjects.simulateObjectDelete(baseObject: BaseRealtimeObject) { - val defaultRealtimeObjects = this as DefaultRealtimeObjects - val existingSiteCode = baseObject.siteTimeserials.keys.first() - val existingSiteSerial = baseObject.siteTimeserials[existingSiteCode]!! - - val deleteObjectProtoMsg = ProtocolMessage(ProtocolMessage.Action.`object`, channelName) - deleteObjectProtoMsg.state = arrayOf(ObjectMessage( - siteCode = existingSiteCode, - serial = existingSiteSerial + "1", // Increment serial to accept new operation - operation = ObjectOperation( - action = ObjectOperationAction.ObjectDelete, - objectId = baseObject.objectId, - ) - )) - defaultRealtimeObjects.handle(deleteObjectProtoMsg) -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt deleted file mode 100644 index d8eaaf697..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt +++ /dev/null @@ -1,75 +0,0 @@ -package io.ably.lib.objects.unit - -import io.ably.lib.objects.ObjectId -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.types.AblyException -import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows -import org.junit.Test -import kotlin.test.assertTrue - -class ObjectIdTest { - - @Test - fun testValidMapObjectId() { - val objectIdString = "map:abc123@1640995200000" - val objectId = ObjectId.fromString(objectIdString) - - assertEquals(ObjectType.Map, objectId.type) - assertEquals("map:abc123@1640995200000", objectId.toString()) - } - - @Test - fun testValidCounterObjectId() { - val objectIdString = "counter:def456@1640995200000" - val objectId = ObjectId.fromString(objectIdString) - - assertEquals(ObjectType.Counter, objectId.type) - assertEquals("counter:def456@1640995200000", objectId.toString()) - } - - @Test - fun testInvalidObjectType() { - val exception = assertThrows(AblyException::class.java) { - ObjectId.fromString("invalid:abc123@1640995200000") - } - assertAblyExceptionError(exception) - } - - @Test - fun testEmptyObjectId() { - val exception1 = assertThrows(AblyException::class.java) { - ObjectId.fromString("") - } - assertAblyExceptionError(exception1) - } - - private fun assertAblyExceptionError( - exception: AblyException - ) { - assertTrue(exception.errorInfo?.message?.contains("Invalid object id:") == true || - exception.errorInfo?.message?.contains("Invalid object type in object id:") == true) - assertEquals(92_000, exception.errorInfo?.code) - assertEquals(500, exception.errorInfo?.statusCode) - } - - @Test - fun testFromInitialValue() { - val objectType = ObjectType.Map - val initialValue = "test-value" - val nonce = "test-nonce" - val msTimestamp = 1640995200000L - - val objectId = ObjectId.fromInitialValue(objectType, initialValue, nonce, msTimestamp) - // Verify the string format follows the expected pattern: type:hash@timestamp - val objectIdString = objectId.toString() - assertTrue(objectIdString.startsWith("map:")) - assertTrue(objectIdString.contains("@")) - assertTrue(objectIdString.endsWith(msTimestamp.toString())) - - val expectedHash = "GSjv-adTaJPL8-382qF3JuIyE4TCc6QKIIqb577pz00" - // Verify the hash value matches expected - val hashPart = objectIdString.substring(4, objectIdString.indexOf("@")) - assertEquals(expectedHash, hashPart) - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt deleted file mode 100644 index 3f63a2d82..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package io.ably.lib.objects.unit - -import io.ably.lib.objects.ObjectsSyncTracker -import org.junit.Test -import org.junit.Assert.* - -class ObjectsSyncTrackerTest { - - @Test - fun `(RTO5a, RTO5a1, RTO5a2) Should parse valid sync channel serial with syncId and cursor`() { - val syncTracker = ObjectsSyncTracker("sync-123:cursor-456") - - assertEquals("sync-123", syncTracker.syncId) - assertFalse(syncTracker.hasSyncStarted("sync-123")) - assertTrue(syncTracker.hasSyncStarted(null)) - assertTrue(syncTracker.hasSyncStarted("sync-124")) - - assertEquals("cursor-456", syncTracker.syncCursor) - assertFalse(syncTracker.hasSyncEnded()) - } - - @Test - fun `(RTO5a5) Should handle null sync channel serial`() { - val syncTracker = ObjectsSyncTracker(null) - - assertNull(syncTracker.syncId) - assertTrue(syncTracker.hasSyncStarted(null)) - - assertNull(syncTracker.syncCursor) - assertTrue(syncTracker.hasSyncEnded()) - } - - @Test - fun `(RTO5a5) Should handle empty sync channel serial`() { - val syncTracker = ObjectsSyncTracker("") - - assertNull(syncTracker.syncId) - assertTrue(syncTracker.hasSyncStarted(null)) - - assertNull(syncTracker.syncCursor) - assertTrue(syncTracker.hasSyncEnded()) - } - - @Test - fun `should handle sync channel serial with special characters`() { - val syncTracker = ObjectsSyncTracker("sync_123-456:cursor_789-012") - - assertEquals("sync_123-456", syncTracker.syncId) - - assertEquals("cursor_789-012", syncTracker.syncCursor) - assertFalse(syncTracker.hasSyncEnded()) - } - - @Test - fun `(RTO5a4) should detect sync sequence ended when sync cursor is empty`() { - val syncTracker = ObjectsSyncTracker("sync-123:") - - assertEquals("sync-123", syncTracker.syncId) - assertTrue(syncTracker.hasSyncStarted(null)) - assertTrue(syncTracker.hasSyncStarted("")) - - assertEquals("", syncTracker.syncCursor) - assertTrue(syncTracker.hasSyncEnded()) - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt deleted file mode 100644 index ec8824e1a..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.ably.lib.objects.unit - -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertNotNull - -class RealtimeObjectsTest { - @Test - fun testChannelObjectGetterTest() = runTest { - val channel = getMockRealtimeChannel("test-channel") - val objects = channel.objects - assertNotNull(objects) - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt deleted file mode 100644 index 17be76951..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt +++ /dev/null @@ -1,169 +0,0 @@ -package io.ably.lib.objects.unit - -import io.ably.lib.objects.* -import io.ably.lib.objects.DefaultRealtimeObjects -import io.ably.lib.objects.ObjectsManager -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livecounter.LiveCounterManager -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.type.livemap.LiveMapManager -import io.ably.lib.realtime.AblyRealtime -import io.ably.lib.realtime.Channel -import io.ably.lib.realtime.ChannelState -import io.ably.lib.transport.ConnectionManager -import io.ably.lib.types.ChannelMode -import io.ably.lib.types.ChannelOptions -import io.ably.lib.types.ClientOptions -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.spyk -import kotlinx.coroutines.CompletableDeferred - -internal fun getMockRealtimeChannel( - channelName: String, - clientId: String = "client1", - channelModes: Array = arrayOf(ChannelMode.object_publish, ChannelMode.object_subscribe)): Channel { - val client = AblyRealtime(ClientOptions().apply { - autoConnect = false - key = "keyName:Value" - this.clientId = clientId - }) - val channelOpts = ChannelOptions().apply { modes = channelModes } - val channel = client.channels.get(channelName, channelOpts) - return spyk(channel) { - every { attach() } answers { - state = ChannelState.attached - } - every { detach() } answers { - state = ChannelState.detached - } - every { subscribe(any(), any()) } returns mockk(relaxUnitFun = true) - every { subscribe(any>(), any()) } returns mockk(relaxUnitFun = true) - every { subscribe(any()) } returns mockk(relaxUnitFun = true) - }.apply { - state = ChannelState.attached - } -} - -internal fun getMockObjectsAdapter(): ObjectsAdapter { - mockkStatic("io.ably.lib.objects.HelpersKt") - return mockk(relaxed = true) { - every { getChannel(any()) } returns getMockRealtimeChannel("testChannelName") - every { connectionManager } returns mockk(relaxed = true) - } -} - -internal fun getMockObjectsPool(): ObjectsPool { - return mockk(relaxed = true) -} - -internal fun ObjectsPool.size(): Int { - val pool = this.getPrivateField>("pool") - return pool.size -} - -internal val BaseRealtimeObject.TombstonedAt: Long? - get() = this.getPrivateField("tombstonedAt") - -/** - * ====================================== - * START - DefaultRealtimeObjects dep mocks - * ====================================== - */ -internal val ObjectsManager.SyncObjectsPool: Map - get() = this.getPrivateField("syncObjectsPool") - -internal val ObjectsManager.BufferedObjectOperations: List - get() = this.getPrivateField("bufferedObjectOperations") - -internal val ObjectsManager.SyncCompletionWaiter: CompletableDeferred? - get() = this.getPrivateField("syncCompletionWaiter") - -internal var DefaultRealtimeObjects.ObjectsManager: ObjectsManager - get() = this.getPrivateField("objectsManager") - set(value) = this.setPrivateField("objectsManager", value) - -internal var DefaultRealtimeObjects.ObjectsPool: ObjectsPool - get() = this.objectsPool - set(value) = this.setPrivateField("objectsPool", value) - -internal fun getDefaultRealtimeObjectsWithMockedDeps( - channelName: String = "testChannelName", - relaxed: Boolean = false -): DefaultRealtimeObjects { - val defaultRealtimeObjects = DefaultRealtimeObjects(channelName, getMockObjectsAdapter()) - // mock objectsPool to allow verification of method calls - if (relaxed) { - defaultRealtimeObjects.ObjectsPool = mockk(relaxed = true) - } else { - defaultRealtimeObjects.ObjectsPool = spyk(defaultRealtimeObjects.objectsPool, recordPrivateCalls = true) - } - // mock objectsManager to allow verification of method calls - if (relaxed) { - defaultRealtimeObjects.ObjectsManager = mockk(relaxed = true) - } else { - defaultRealtimeObjects.ObjectsManager = spyk(defaultRealtimeObjects.ObjectsManager, recordPrivateCalls = true) - } - return defaultRealtimeObjects -} -/** - * ====================================== - * END - DefaultRealtimeObjects dep mocks - * ====================================== - */ - -/** - * ====================================== - * START - DefaultLiveCounter dep mocks - * ====================================== - */ -internal var DefaultLiveCounter.LiveCounterManager: LiveCounterManager - get() = this.getPrivateField("liveCounterManager") - set(value) = this.setPrivateField("liveCounterManager", value) - -internal fun getDefaultLiveCounterWithMockedDeps( - objectId: String = "counter:testCounter@1", - relaxed: Boolean = false -): DefaultLiveCounter { - val defaultLiveCounter = DefaultLiveCounter.zeroValue(objectId, getDefaultRealtimeObjectsWithMockedDeps()) - if (relaxed) { - defaultLiveCounter.LiveCounterManager = mockk(relaxed = true) - } else { - defaultLiveCounter.LiveCounterManager = spyk(defaultLiveCounter.LiveCounterManager, recordPrivateCalls = true) - } - return defaultLiveCounter -} -/** - * ====================================== - * END - DefaultLiveCounter dep mocks - * ====================================== - */ - -/** - * ====================================== - * START - DefaultLiveMap dep mocks - * ====================================== - */ -internal var DefaultLiveMap.LiveMapManager: LiveMapManager - get() = this.getPrivateField("liveMapManager") - set(value) = this.setPrivateField("liveMapManager", value) - -internal fun getDefaultLiveMapWithMockedDeps( - objectId: String = "map:testMap@1", - relaxed: Boolean = false -): DefaultLiveMap { - val defaultLiveMap = DefaultLiveMap.zeroValue(objectId, getDefaultRealtimeObjectsWithMockedDeps()) - if (relaxed) { - defaultLiveMap.LiveMapManager = mockk(relaxed = true) - } else { - defaultLiveMap.LiveMapManager = spyk(defaultLiveMap.LiveMapManager, recordPrivateCalls = true) - } - return defaultLiveMap -} -/** - * ====================================== - * END - DefaultLiveMap dep mocks - * ====================================== - */ diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt deleted file mode 100644 index a6cd9bcf8..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt +++ /dev/null @@ -1,301 +0,0 @@ -package io.ably.lib.objects.unit - -import io.ably.lib.objects.* -import io.ably.lib.objects.assertWaiter -import io.ably.lib.types.AblyException -import io.ably.lib.types.ErrorInfo -import kotlinx.coroutines.* -import kotlinx.coroutines.test.* -import org.junit.Test -import org.junit.Assert.* -import java.util.concurrent.CancellationException - -class UtilsTest { - - @Test - fun testGenerateNonce() { - // Test basic functionality - val nonce1 = generateNonce() - val nonce2 = generateNonce() - - assertEquals(16, nonce1.length) - assertEquals(16, nonce2.length) - assertNotEquals(nonce1, nonce2) // Should be random - - // Test character set - val validChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - val nonce = generateNonce() - nonce.forEach { char -> - assertTrue("Nonce should only contain valid characters", validChars.contains(char)) - } - } - - @Test - fun testStringByteSize() { - // Test ASCII strings - assertEquals(5, "Hello".byteSize) - assertEquals(0, "".byteSize) - assertEquals(1, "A".byteSize) - - // Test non-ASCII strings - assertEquals(3, "你".byteSize) // Chinese character - assertEquals(4, "😊".byteSize) // Emoji - assertEquals(6, "你好".byteSize) // Two Chinese characters - } - - @Test - fun testErrorCreationFunctions() { - // Test clientError - val clientEx = clientError("Bad request") - assertEquals("Bad request", clientEx.errorInfo.message) - assertEquals(ErrorCode.BadRequest.code, clientEx.errorInfo.code) - assertEquals(HttpStatusCode.BadRequest.code, clientEx.errorInfo.statusCode) - - // Test serverError - val serverEx = serverError("Internal error") - assertEquals("Internal error", serverEx.errorInfo.message) - assertEquals(ErrorCode.InternalError.code, serverEx.errorInfo.code) - assertEquals(HttpStatusCode.InternalServerError.code, serverEx.errorInfo.statusCode) - - // Test objectError - val objectEx = objectError("Invalid object") - assertEquals("Invalid object", objectEx.errorInfo.message) - assertEquals(ErrorCode.InvalidObject.code, objectEx.errorInfo.code) - assertEquals(HttpStatusCode.InternalServerError.code, objectEx.errorInfo.statusCode) - - // Test objectError with cause - val cause = RuntimeException("Original error") - val objectExWithCause = objectError("Invalid object", cause) - assertEquals("Invalid object", objectExWithCause.errorInfo.message) - assertEquals(cause, objectExWithCause.cause) - } - - @Test - fun testAblyExceptionCreation() { - // Test with error message and codes - val ex = ablyException("Test error", ErrorCode.BadRequest, HttpStatusCode.BadRequest) - assertEquals("Test error", ex.errorInfo.message) - assertEquals(ErrorCode.BadRequest.code, ex.errorInfo.code) - assertEquals(HttpStatusCode.BadRequest.code, ex.errorInfo.statusCode) - - // Test with ErrorInfo - val errorInfo = ErrorInfo("Custom error", 400, 40000) - val ex2 = ablyException(errorInfo) - assertEquals("Custom error", ex2.errorInfo.message) - assertEquals(400, ex2.errorInfo.statusCode) - assertEquals(40000, ex2.errorInfo.code) - - // Test with cause - val cause = RuntimeException("Cause") - val ex3 = ablyException(errorInfo, cause) - assertEquals(cause, ex3.cause) - } - - @Test - fun testObjectsAsyncScopeLaunchWithCallback() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var callbackExecuted = false - var resultReceived: String? = null - - val callback = object : ObjectsCallback { - override fun onSuccess(result: String) { - callbackExecuted = true - resultReceived = result - } - - override fun onError(exception: AblyException) { - fail("Should not call onError for successful execution") - } - } - - asyncScope.launchWithCallback(callback) { - delay(10) // Simulate async work - "test result" - } - - // Wait for callback to be executed - assertWaiter { callbackExecuted } - - assertTrue("Callback should be executed", callbackExecuted) - assertEquals("test result", resultReceived) - } - - @Test - fun testObjectsAsyncScopeLaunchWithCallbackError() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var errorReceived: AblyException? = null - - val callback = object : ObjectsCallback { - override fun onSuccess(result: String) { - fail("Should not call onSuccess for error case") - } - - override fun onError(exception: AblyException) { - errorReceived = exception - } - } - - asyncScope.launchWithCallback(callback) { - delay(10) - throw AblyException.fromErrorInfo(ErrorInfo("Test error", 400, 40000)) - } - - // Wait for error to be received - assertWaiter { errorReceived != null } - - assertNotNull("Error should be received", errorReceived) - assertEquals("Test error", errorReceived?.errorInfo?.message) - assertEquals(400, errorReceived?.errorInfo?.statusCode) - } - - @Test - fun testObjectsAsyncScopeLaunchWithVoidCallback() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var callbackExecuted = false - - val callback = object : ObjectsCallback { - override fun onSuccess(result: Void?) { - callbackExecuted = true - } - - override fun onError(exception: AblyException) { - fail("Should not call onError for successful execution") - } - } - - asyncScope.launchWithVoidCallback(callback) { - delay(10) // Simulate async work - } - - // Wait for callback to be executed - assertWaiter { callbackExecuted } - - assertTrue("Callback should be executed", callbackExecuted) - } - - @Test - fun testObjectsAsyncScopeLaunchWithVoidCallbackError() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var errorReceived: AblyException? = null - - val callback = object : ObjectsCallback { - override fun onSuccess(result: Void?) { - fail("Should not call onSuccess for error case") - } - - override fun onError(exception: AblyException) { - errorReceived = exception - } - } - - asyncScope.launchWithVoidCallback(callback) { - delay(10) - throw AblyException.fromErrorInfo(ErrorInfo("Test error", 500, 50000)) - } - - // Wait for error to be received - assertWaiter { errorReceived != null } - - assertNotNull("Error should be received", errorReceived) - assertEquals("Test error", errorReceived?.errorInfo?.message) - assertEquals(500, errorReceived?.errorInfo?.statusCode) - } - - @Test - fun testObjectsAsyncScopeCallbackExceptionHandling() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var callback1Called = false - var callback2Called = false - - val callback1 = object : ObjectsCallback { - override fun onSuccess(result: String) { - callback1Called = true - throw RuntimeException("Callback exception") - } - - override fun onError(exception: AblyException) { - fail("Should not call onError when onSuccess throws") - } - } - - asyncScope.launchWithCallback(callback1) { "test result" } - // Wait for callback to be called - assertWaiter { callback1Called } - - val callback2 = object : ObjectsCallback { - override fun onSuccess(result: String) { - callback2Called = true - } - - override fun onError(exception: AblyException) { - fail("Should not call onError when onSuccess throws") - } - } - - asyncScope.launchWithCallback(callback2) { "test result" } - // Callback 2 should be called even if callback 1 throws an exception - assertWaiter { callback2Called } - } - - @Test - fun testObjectsAsyncScopeCancel() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var errorReceived = false - - val callback = object : ObjectsCallback { - override fun onSuccess(result: String) { - fail("Should not call onSuccess") - } - - override fun onError(exception: AblyException) { - errorReceived = true - } - } - - asyncScope.launchWithCallback(callback) { - delay(10000) // Long delay - "test result" - } - - // Cancel immediately - asyncScope.cancel(CancellationException("Test cancellation")) - - // Wait a bit to ensure cancellation takes effect - assertWaiter { errorReceived } - } - - @Test - fun testObjectsAsyncScopeNonAblyException() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var errorReceived = false - var error: AblyException? = null - - val callback = object : ObjectsCallback { - override fun onSuccess(result: String) { - fail("Should not call onSuccess for error case") - } - - override fun onError(exception: AblyException) { - errorReceived = true - error = exception - } - } - - asyncScope.launchWithCallback(callback) { - delay(10) - throw RuntimeException("Non-Ably exception") - } - - // Wait for error to be received - assertWaiter { errorReceived } - - // Non-Ably exceptions should be wrapped in AblyException - assertNotNull("Non-Ably exceptions should be wrapped in AblyException", error) - assertEquals("Error executing operation", error?.errorInfo?.message) - assertEquals(ErrorCode.BadRequest.code, error?.errorInfo?.code) - assertEquals(HttpStatusCode.BadRequest.code, error?.errorInfo?.statusCode) - - assertTrue(error?.cause is RuntimeException) - assertEquals("Non-Ably exception", error?.cause?.message) - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultRealtimeObjectsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultRealtimeObjectsTest.kt deleted file mode 100644 index 0a0ae9907..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultRealtimeObjectsTest.kt +++ /dev/null @@ -1,489 +0,0 @@ -package io.ably.lib.objects.unit.objects - -import io.ably.lib.objects.* -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.ObjectsState -import io.ably.lib.objects.ROOT_OBJECT_ID -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.type.livemap.LiveMapEntry -import io.ably.lib.objects.unit.BufferedObjectOperations -import io.ably.lib.objects.unit.ObjectsManager -import io.ably.lib.objects.unit.SyncObjectsPool -import io.ably.lib.objects.unit.getMockObjectsAdapter -import io.ably.lib.objects.unit.getDefaultRealtimeObjectsWithMockedDeps -import io.ably.lib.objects.unit.getMockRealtimeChannel -import io.ably.lib.objects.unit.size -import io.ably.lib.realtime.ChannelState -import io.ably.lib.types.AblyException -import io.ably.lib.types.ErrorInfo -import io.ably.lib.types.ProtocolMessage -import io.mockk.every -import io.mockk.verify -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -class DefaultRealtimeObjectsTest { - - private val testInstances = mutableListOf() - - @After - fun tearDown() { - val cleanupError = AblyException.fromErrorInfo(ErrorInfo("test cleanup", 500)) - testInstances.forEach { it.dispose(cleanupError) } - testInstances.clear() - } - - @Test - fun `(RTO4, RTO4a) When channel ATTACHED with HAS_OBJECTS flag true should start sync sequence`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // RTO4a - If the HAS_OBJECTS flag is 1, the server will shortly perform an OBJECT_SYNC sequence - defaultRealtimeObjects.handleStateChange(ChannelState.attached, true) - - assertWaiter { defaultRealtimeObjects.state == ObjectsState.Syncing } - - // It is expected that the client will start a new sync sequence - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.startNewSync(null) - } - verify(exactly = 0) { - defaultRealtimeObjects.ObjectsManager.endSync() - } - } - - @Test - fun `(RTO4, RTO4b) When channel ATTACHED with HAS_OBJECTS flag false should complete sync immediately`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Set up some objects in objectPool that should be cleared - val rootObject = defaultRealtimeObjects.objectsPool.get(ROOT_OBJECT_ID) as DefaultLiveMap - rootObject.data["key1"] = LiveMapEntry(data = ObjectData("testValue1")) - defaultRealtimeObjects.objectsPool.set("counter:testObject@1", DefaultLiveCounter.zeroValue("counter:testObject@1", defaultRealtimeObjects)) - assertEquals(2, defaultRealtimeObjects.objectsPool.size(), "RTO4b - Should have 2 objects before state change") - - // RTO4b - If the HAS_OBJECTS flag is 0, the sync sequence must be considered complete immediately - defaultRealtimeObjects.handleStateChange(ChannelState.attached, false) - - // Verify expected outcomes - assertWaiter { defaultRealtimeObjects.state == ObjectsState.Synced } // RTO4b4 - - verify(exactly = 1) { - defaultRealtimeObjects.objectsPool.resetToInitialPool(true) - } - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.endSync() - } - - assertEquals(0, defaultRealtimeObjects.ObjectsManager.SyncObjectsPool.size) // RTO4b3 - assertEquals(0, defaultRealtimeObjects.ObjectsManager.BufferedObjectOperations.size) // RTO4d - assertEquals(1, defaultRealtimeObjects.objectsPool.size()) // RTO4b1 - Only root remains - assertEquals(rootObject, defaultRealtimeObjects.objectsPool.get(ROOT_OBJECT_ID)) // points to previously created root object - assertEquals(0, rootObject.data.size) // RTO4b2 - root object must be empty - } - - @Test - fun `(RTO4) When channel ATTACHED from INITIALIZED state should always start sync`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Ensure we're in INITIALIZED state - defaultRealtimeObjects.state = ObjectsState.Initialized - - // RTO4a - Should start sync even with HAS_OBJECTS flag false when in INITIALIZED state - defaultRealtimeObjects.handleStateChange(ChannelState.attached, false) - - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.startNewSync(null) - } - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.endSync() - } - } - - @Test - fun `(RTO5, RTO7) Should delegate OBJECT and OBJECT_SYNC protocolMessage to ObjectManager`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps(relaxed = true) - - // Create test ObjectMessage for OBJECT action - val objectMessage = ObjectMessage( - id = "testId", - timestamp = 1234567890L, - connectionId = "testConnectionId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:testObject@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "serial1", - siteCode = "site1" - ) - // Create ProtocolMessage with OBJECT action - val objectProtocolMessage = ProtocolMessage(ProtocolMessage.Action.`object`).apply { - id = "protocolId1" - channel = "testChannel" - channelSerial = "channelSerial1" - timestamp = 1234567890L - state = arrayOf(objectMessage) - } - // Test OBJECT action delegation - defaultRealtimeObjects.handle(objectProtocolMessage) - - // Verify that handleObjectMessages was called with the correct parameters - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.handleObjectMessages(listOf(objectMessage)) - } - - // Create test ObjectMessage for OBJECT_SYNC action - val objectSyncMessage = ObjectMessage( - id = "testSyncId", - timestamp = 1234567890L, - connectionId = "testSyncConnectionId", - objectState = ObjectState( - objectId = "map:testObject@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "syncSerial1"), - ), - serial = "syncSerial1", - siteCode = "site1" - ) - // Create ProtocolMessage with OBJECT_SYNC action - val objectSyncProtocolMessage = ProtocolMessage(ProtocolMessage.Action.object_sync).apply { - id = "protocolId2" - channel = "testChannel" - channelSerial = "syncChannelSerial1" - timestamp = 1234567890L - state = arrayOf(objectSyncMessage) - } - // Test OBJECT_SYNC action delegation - defaultRealtimeObjects.handle(objectSyncProtocolMessage) - // Verify that handleObjectSyncMessages was called with the correct parameters - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.handleObjectSyncMessages(listOf(objectSyncMessage), "syncChannelSerial1") - } - } - - @Test - fun `(RTO20e1) handleStateChange(DETACHED) fails pending ACK waiters with error 92008`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Capture the error passed to failBufferedAcks via a CompletableDeferred - val capturedError = CompletableDeferred() - every { defaultRealtimeObjects.ObjectsManager.failBufferedAcks(any()) } answers { - capturedError.complete(firstArg()) - callOriginal() - } - - defaultRealtimeObjects.handleStateChange(ChannelState.detached, false) - - val error = capturedError.await() - assertEquals(92008, error.errorInfo.code) // PublishAndApplyFailedDueToChannelState - } - - @Test - fun `(RTO20e1) handleStateChange(SUSPENDED) fails pending ACK waiters with error 92008`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - val capturedError = CompletableDeferred() - every { defaultRealtimeObjects.ObjectsManager.failBufferedAcks(any()) } answers { - capturedError.complete(firstArg()) - callOriginal() - } - - defaultRealtimeObjects.handleStateChange(ChannelState.suspended, false) - - val error = capturedError.await() - assertEquals(92008, error.errorInfo.code) // PublishAndApplyFailedDueToChannelState - } - - @Test - fun `(RTO20e1) handleStateChange(FAILED) fails pending ACK waiters and propagates channel reason`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Override the channel returned by the adapter to carry a non-null reason - val channelReason = ErrorInfo("channel failed due to auth error", 40100, 401) - val channelWithReason = getMockRealtimeChannel("testChannelName") - channelWithReason.reason = channelReason - every { defaultRealtimeObjects.adapter.getChannel(any()) } returns channelWithReason - - val capturedError = CompletableDeferred() - every { defaultRealtimeObjects.ObjectsManager.failBufferedAcks(any()) } answers { - capturedError.complete(firstArg()) - callOriginal() - } - - defaultRealtimeObjects.handleStateChange(ChannelState.failed, false) - - val error = capturedError.await() - assertEquals(92008, error.errorInfo.code) - val causeException = error.cause as? AblyException - assertNotNull(causeException, "Error cause must include the channel's reason") - assertEquals(channelReason.code, causeException.errorInfo.code) - assertEquals(channelReason.message, causeException.errorInfo.message) - } - - @Test - fun `(RTO4) handleStateChange(SUSPENDED) does NOT clear objects data`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Use the failBufferedAcks call as a signal that the state-change coroutine has run to completion - val failCalled = CompletableDeferred() - every { defaultRealtimeObjects.ObjectsManager.failBufferedAcks(any()) } answers { - callOriginal() - failCalled.complete(Unit) - } - - defaultRealtimeObjects.handleStateChange(ChannelState.suspended, false) - - // For SUSPENDED, the coroutine ends immediately after failBufferedAcks (no clear calls) - failCalled.await() - - verify(exactly = 0) { defaultRealtimeObjects.objectsPool.clearObjectsData(any()) } - verify(exactly = 0) { defaultRealtimeObjects.ObjectsManager.clearSyncObjectsPool() } - } - - @Test - fun `(RTO4) handleStateChange(DETACHED) clears objects data and sync pool`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Use clearSyncObjectsPool (the last operation in the coroutine) as the completion signal - val syncPoolCleared = CompletableDeferred() - every { defaultRealtimeObjects.ObjectsManager.clearSyncObjectsPool() } answers { - callOriginal() - syncPoolCleared.complete(Unit) - } - - defaultRealtimeObjects.handleStateChange(ChannelState.detached, false) - - syncPoolCleared.await() - - verify(exactly = 1) { defaultRealtimeObjects.objectsPool.clearObjectsData(false) } - verify(exactly = 1) { defaultRealtimeObjects.ObjectsManager.clearSyncObjectsPool() } - } - - @Test - fun `(RTO4d) ATTACHED with hasObjects=true still clears bufferedObjectOperations`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - val manager = defaultRealtimeObjects.ObjectsManager - - // Pre-populate bufferedObjectOperations with a dummy operation - @Suppress("UNCHECKED_CAST") - (manager.BufferedObjectOperations as MutableList).add( - ObjectMessage( - id = "pre-attach-op", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ) - ) - ) - assertEquals(1, manager.BufferedObjectOperations.size) - - // ATTACHED with hasObjects=true — RTO4d must clear the buffer before starting sync - defaultRealtimeObjects.handleStateChange(ChannelState.attached, true) - - assertWaiter { defaultRealtimeObjects.state == ObjectsState.Syncing } - assertEquals(0, manager.BufferedObjectOperations.size, "RTO4d - buffer must be cleared unconditionally on ATTACHED") - } - - @Test - fun `(RTO4d) Pre-ATTACHED buffered operations are discarded, not applied after sync`() = runTest { - val defaultRealtimeObjects = DefaultRealtimeObjects("testChannel", getMockObjectsAdapter()) - .also { testInstances.add(it) } - - // Set up a counter in the pool - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - // Pre-populate bufferedObjectOperations with a COUNTER_INC — simulates an op received before ATTACHED - @Suppress("UNCHECKED_CAST") - (objectsManager.BufferedObjectOperations as MutableList).add( - ObjectMessage( - id = "pre-attach-inc", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ) - ) - ) - assertEquals(1, objectsManager.BufferedObjectOperations.size) - - // ATTACHED with hasObjects=true: RTO4d clears the buffer, then starts sync - defaultRealtimeObjects.handleStateChange(ChannelState.attached, true) - assertWaiter { defaultRealtimeObjects.state == ObjectsState.Syncing } - assertEquals(0, objectsManager.BufferedObjectOperations.size, "buffer must be cleared by RTO4d") - - // Complete sync by calling handleObjectSyncMessages directly (sequentialScope is idle now) - objectsManager.handleObjectSyncMessages( - listOf( - ObjectMessage( - id = "sync-msg-1", - objectState = ObjectState( - objectId = "counter:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - counter = ObjectsCounter(count = 0.0) - ) - ) - ), - "sync-id:" // empty cursor — ends sync (RTO5a4) - ) - - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state) - - // The pre-ATTACHED COUNTER_INC was discarded — counter should remain at 0 - assertEquals(0.0, counter.data.get(), "RTO4d - pre-ATTACHED buffered op must be discarded, not applied after sync") - } - - @Test - fun `(RTO5a2b removed) Buffered operations survive a server-initiated resync (new OBJECT_SYNC without ATTACHED)`() { - val defaultRealtimeObjects = DefaultRealtimeObjects("testChannel", getMockObjectsAdapter()) - .also { testInstances.add(it) } - - // Set up a counter in the pool - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(5.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - // sync-1 is in progress - objectsManager.startNewSync("sync-1") - assertEquals(ObjectsState.Syncing, defaultRealtimeObjects.state) - - // Buffer a COUNTER_INC during sync-1 - objectsManager.handleObjectMessages( - listOf( - ObjectMessage( - id = "channel-op-1", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 3.0) - ), - serial = "serial-op-1", - siteCode = "site1" - ) - ) - ) - assertEquals(1, objectsManager.BufferedObjectOperations.size, "op buffered during sync-1") - - // Server sends a new OBJECT_SYNC with a different sync-id — triggers startNewSync("sync-2") internally - // OLD behaviour (RTO5a2b): startNewSync would have cleared bufferedObjectOperations here - // NEW behaviour (RTO5a2b removed): buffer is preserved - objectsManager.handleObjectSyncMessages( - listOf( - ObjectMessage( - id = "sync2-msg-1", - objectState = ObjectState( - objectId = "counter:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "resync-serial"), - counter = ObjectsCounter(count = 5.0) - ) - ) - ), - "sync-2:cursor-1" // has cursor — not ending yet - ) - - assertEquals(1, objectsManager.BufferedObjectOperations.size, - "startNewSync must NOT clear bufferedObjectOperations (RTO5a2b removed)") - - // Complete sync-2 (ending serial, no new messages) - objectsManager.handleObjectSyncMessages(emptyList(), "sync-2:") - - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state) - // sync-2 restored counter to 5.0; buffered COUNTER_INC (+3.0) applied after sync → 8.0 - assertEquals(8.0, counter.data.get(), - "buffered COUNTER_INC from before server-initiated resync must be applied after sync completes") - } - - @Test - fun `(OM2) Populate objectMessage missing id, timestamp and connectionId from protocolMessage`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Capture the ObjectMessages that are passed to ObjectsManager methods - var capturedObjectMessages: List? = null - var capturedObjectSyncMessages: List? = null - - // Mock the ObjectsManager to capture the messages - defaultRealtimeObjects.ObjectsManager.apply { - every { handleObjectMessages(any>()) } answers { - capturedObjectMessages = firstArg() - } - every { handleObjectSyncMessages(any(), any()) } answers { - capturedObjectSyncMessages = firstArg() - } - } - - // Create ObjectMessage with missing fields (id, timestamp, connectionId) - val objectMessageWithMissingFields = ObjectMessage( - id = null, // OM2a - missing id - timestamp = null, // OM2e - missing timestamp - connectionId = null, // OM2c - missing connectionId - ) - - // Create ProtocolMessage with OBJECT action and populated fields - val objectProtocolMessage = ProtocolMessage(ProtocolMessage.Action.`object`).apply { - id = "protocolId1" - channel = "testChannel" - channelSerial = "channelSerial1" - connectionId = "protocolConnectionId" - timestamp = 1234567890L - state = arrayOf(objectMessageWithMissingFields) - } - - // Test OBJECT action - should populate missing fields - defaultRealtimeObjects.handle(objectProtocolMessage) - - // Verify that the captured ObjectMessage has populated fields - assertWaiter { capturedObjectMessages != null } - assertEquals(1, capturedObjectMessages!!.size) - - val populatedObjectMessage = capturedObjectMessages!![0] - assertEquals("protocolId1:0", populatedObjectMessage.id) // OM2a - id should be protocolId:index - assertEquals(1234567890L, populatedObjectMessage.timestamp) // OM2e - timestamp from protocol message - assertEquals("protocolConnectionId", populatedObjectMessage.connectionId) // OM2c - connectionId from protocol message - - - // Create ObjectMessage with missing fields for OBJECT_SYNC - val objectSyncMessageWithMissingFields = ObjectMessage( - id = null, // OM2a - missing id - timestamp = null, // OM2e - missing timestamp - connectionId = null, // OM2c - missing connectionId - ) - - // Create ProtocolMessage with OBJECT_SYNC action and populated fields - val objectSyncProtocolMessage = ProtocolMessage(ProtocolMessage.Action.object_sync).apply { - id = "protocolId2" - channel = "testChannel" - channelSerial = "syncChannelSerial1" - connectionId = "protocolConnectionId" - timestamp = 9876543210L - state = arrayOf(objectSyncMessageWithMissingFields) - } - - // Test OBJECT_SYNC action - should populate missing fields - defaultRealtimeObjects.handle(objectSyncProtocolMessage) - - // Verify that the captured ObjectMessage has populated fields - assertWaiter { capturedObjectSyncMessages != null } - assertEquals(1, capturedObjectSyncMessages!!.size) - - val populatedObjectSyncMessage = capturedObjectSyncMessages!![0] - assertEquals("protocolId2:0", populatedObjectSyncMessage.id) // OM2a - id should be protocolId:index - assertEquals(9876543210L, populatedObjectSyncMessage.timestamp) // OM2e - timestamp from protocol message - assertEquals("protocolConnectionId", populatedObjectSyncMessage.connectionId) // OM2c - connectionId from protocol message - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt deleted file mode 100644 index 2a9ac5b13..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt +++ /dev/null @@ -1,944 +0,0 @@ -package io.ably.lib.objects.unit.objects - -import io.ably.lib.objects.* -import io.ably.lib.objects.DefaultRealtimeObjects -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.ObjectsOperationSource -import io.ably.lib.objects.ObjectsState -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.unit.* -import io.ably.lib.objects.unit.getDefaultRealtimeObjectsWithMockedDeps -import io.ably.lib.types.AblyException -import io.ably.lib.types.ErrorInfo -import io.ably.lib.util.Log -import io.mockk.* -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.yield -import org.junit.Test -import kotlin.test.* - -class ObjectsManagerTest { - - // Track instances created in tests to ensure background coroutines are cancelled at teardown - private val testInstances = mutableListOf() - - private fun makeRealtimeObjects(channelName: String = "testChannel"): DefaultRealtimeObjects { - return DefaultRealtimeObjects(channelName, getMockObjectsAdapter()).also { testInstances.add(it) } - } - - @Test - fun `(RTO5) ObjectsManager should handle object sync messages`() { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - assertEquals(ObjectsState.Initialized, defaultRealtimeObjects.state, "Initial state should be INITIALIZED") - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - mockZeroValuedObjects() - - // Populate objectsPool with existing objects - val objectsPool = defaultRealtimeObjects.ObjectsPool - objectsPool.set("map:testObject@1", mockk(relaxed = true)) - objectsPool.set("counter:testObject@4", mockk(relaxed = true)) - - // Incoming object messages - val objectMessage1 = ObjectMessage( - id = "testId1", - objectState = ObjectState( - objectId = "map:testObject@1", // already exists in pool - tombstone = false, - siteTimeserials = mapOf("site1" to "syncSerial1"), - map = ObjectsMap(), - ) - ) - val objectMessage2 = ObjectMessage( - id = "testId2", - objectState = ObjectState( - objectId = "counter:testObject@2", // Does not exist in pool - tombstone = false, - siteTimeserials = mapOf("site1" to "syncSerial1"), - counter = ObjectsCounter(count = 20.0) - ) - ) - val objectMessage3 = ObjectMessage( - id = "testId3", - objectState = ObjectState( - objectId = "map:testObject@3", // Does not exist in pool - tombstone = false, - siteTimeserials = mapOf("site1" to "syncSerial1"), - map = ObjectsMap(), - ) - ) - // Should start and end sync, apply object states, and create new objects for missing ones - objectsManager.handleObjectSyncMessages(listOf(objectMessage1, objectMessage2, objectMessage3), "sync-123:") - - verify(exactly = 1) { - objectsManager.startNewSync("sync-123") - } - verify(exactly = 1) { - objectsManager.endSync() // - } - val newlyCreatedObjects = mutableListOf() - verify(exactly = 2) { - objectsManager["createObjectFromState"](capture(newlyCreatedObjects)) - } - assertEquals("counter:testObject@2", newlyCreatedObjects[0].objectId) - assertEquals("map:testObject@3", newlyCreatedObjects[1].objectId) - - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state, "State should be SYNCED after sync sequence") - // After sync `counter:testObject@4` will be removed from pool - assertNull(objectsPool.get("counter:testObject@4")) - assertEquals(4, objectsPool.size(), "Objects pool should contain 4 objects after sync including root") - assertNotNull(objectsPool.get(ROOT_OBJECT_ID), "Root object should still exist in pool") - val testObject1 = objectsPool.get("map:testObject@1") - assertNotNull(testObject1, "map:testObject@1 should exist in pool after sync") - verify(exactly = 1) { - testObject1.applyObjectSync(any()) - } - val testObject2 = objectsPool.get("counter:testObject@2") - assertNotNull(testObject2, "counter:testObject@2 should exist in pool after sync") - verify(exactly = 1) { - testObject2.applyObjectSync(any()) - } - val testObject3 = objectsPool.get("map:testObject@3") - assertNotNull(testObject3, "map:testObject@3 should exist in pool after sync") - verify(exactly = 1) { - testObject3.applyObjectSync(any()) - } - } - - @Test - fun `(RTO8) ObjectsManager should apply object operation when state is synced`() { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - defaultRealtimeObjects.state = ObjectsState.Synced // Ensure we're in SYNCED state - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - mockZeroValuedObjects() - - // Populate objectsPool with existing objects - val objectsPool = defaultRealtimeObjects.ObjectsPool - objectsPool.set("map:testObject@1", mockk(relaxed = true)) - - // Incoming object messages with operation field instead of objectState - val objectMessage1 = ObjectMessage( - id = "testId1", - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, // Assuming this is the right action for maps - objectId = "map:testObject@1", // already exists in pool - ), - serial = "serial1", - siteCode = "site1" - ) - - val objectMessage2 = ObjectMessage( - id = "testId2", - operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, // Set the counter value - objectId = "counter:testObject@2", // Does not exist in pool - ), - serial = "serial2", - siteCode = "site1" - ) - - val objectMessage3 = ObjectMessage( - id = "testId3", - operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testObject@3", // Does not exist in pool - ), - serial = "serial3", - siteCode = "site1" - ) - - // RTO8b - Apply messages immediately if synced - objectsManager.handleObjectMessages(listOf(objectMessage1, objectMessage2, objectMessage3)) - assertEquals(0, objectsManager.BufferedObjectOperations.size, "No buffer needed in SYNCED state") - - assertEquals(4, objectsPool.size(), "Objects pool should contain 4 objects including root") - assertNotNull(objectsPool.get(ROOT_OBJECT_ID), "Root object should still exist in pool") - - val testObject1 = objectsPool.get("map:testObject@1") - assertNotNull(testObject1, "map:testObject@1 should exist in pool after sync") - verify(exactly = 1) { - testObject1.applyObject(objectMessage1, any()) - } - val testObject2 = objectsPool.get("counter:testObject@2") - assertNotNull(testObject2, "counter:testObject@2 should exist in pool after sync") - verify(exactly = 1) { - testObject2.applyObject(objectMessage2, any()) - } - val testObject3 = objectsPool.get("map:testObject@3") - assertNotNull(testObject3, "map:testObject@3 should exist in pool after sync") - verify(exactly = 1) { - testObject3.applyObject(objectMessage3, any()) - } - } - - @Test - fun `(RTO7) ObjectsManager should buffer operations when not in sync, apply them after synced`() { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - assertEquals(ObjectsState.Initialized, defaultRealtimeObjects.state, "Initial state should be INITIALIZED") - - val objectsManager = defaultRealtimeObjects.ObjectsManager - assertEquals(0, objectsManager.BufferedObjectOperations.size, "RTO7a1 - Initial buffer should be empty") - - val objectsPool = defaultRealtimeObjects.ObjectsPool - assertEquals(1, objectsPool.size(), "RTO7a2 - Initial pool should contain only root object") - - mockZeroValuedObjects() - - // Set state to SYNCING - defaultRealtimeObjects.state = ObjectsState.Syncing - - val objectMessage = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "counter:testObject@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTO7a - Buffer operations during sync - objectsManager.handleObjectMessages(listOf(objectMessage)) - - verify(exactly = 0) { - objectsManager["applyObjectMessages"](any>(), any()) - } - assertEquals(1, objectsManager.BufferedObjectOperations.size) - assertEquals(objectMessage, objectsManager.BufferedObjectOperations[0]) - assertEquals(1, objectsPool.size(), "Pool should still contain only root object during sync") - - // RTO7 - Apply buffered operations after sync - objectsManager.endSync() // End sync without new sync - verify(exactly = 1) { - objectsManager["applyObjectMessages"](any>(), any()) - } - assertEquals(0, objectsManager.BufferedObjectOperations.size) - assertEquals(2, objectsPool.size(), "Pool should contain 2 objects after applying buffered operations") - assertNotNull(objectsPool.get("counter:testObject@1"), "Counter object should be created after sync") - assertTrue(objectsPool.get("counter:testObject@1") is DefaultLiveCounter, "Should create a DefaultLiveCounter object") - } - - @Test - fun `(RTO23 COUNTER_INC) applyAckResult applies COUNTER_INC locally and tracks serial in appliedOnAckSerials`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-ack-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - objectsManager.applyAckResult(listOf(msg)) - - // Verify operation applied locally (RTO23) - assertEquals(5.0, counter.data.get(), "COUNTER_INC should be applied locally on ACK") - // Serial added to appliedOnAckSerials (RTO9a2a4) - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-ack-01"), - "serial should be in appliedOnAckSerials") - // siteTimeserials NOT updated (LOCAL source, RTLC7c) - assertFalse(counter.siteTimeserials.containsKey("site1"), - "siteTimeserials should NOT be updated for LOCAL source") - } - - @Test - fun `(RTO23 MAP_SET) applyAckResult applies MAP_SET locally and tracks serial in appliedOnAckSerials`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val liveMap = DefaultLiveMap.zeroValue("map:testMap@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("map:testMap@1", liveMap) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet(key = "key1", value = ObjectData(string = "value1")) - ), - serial = "ser-map-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - objectsManager.applyAckResult(listOf(msg)) - - // Verify entry was set (LOCAL source) - assertEquals("value1", liveMap.data["key1"]?.data?.string, - "MAP_SET should be applied locally on ACK") - // Entry timeserial should be updated (within LiveMapManager, regardless of source) - assertEquals("ser-map-01", liveMap.data["key1"]?.timeserial, - "entry timeserial should be set by MAP_SET") - // Serial added to appliedOnAckSerials - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-map-01"), - "serial should be in appliedOnAckSerials") - // Object-level siteTimeserials NOT updated (LOCAL source, RTLM15c) - assertFalse(liveMap.siteTimeserials.containsKey("site1"), - "siteTimeserials should NOT be updated for LOCAL source") - } - - @Test - fun `(RTO9a3) echo CHANNEL message is deduplicated - serial removed, data NOT re-applied`() { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(10.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - // Simulate: serial already applied locally on ACK - defaultRealtimeObjects.appliedOnAckSerials.add("ser-echo-01") - - val echoMsg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-echo-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - objectsManager.handleObjectMessages(listOf(echoMsg)) - - // Data NOT double-applied (RTO9a3) - assertEquals(10.0, counter.data.get(), "data should NOT be re-applied on echo dedup") - // Serial removed from appliedOnAckSerials (RTO9a3) - assertFalse(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-echo-01"), - "serial should be removed from appliedOnAckSerials after dedup") - // siteTimeserials NOT updated - discarded without further action (RTO9a3) - assertNull(counter.siteTimeserials["site1"], - "siteTimeserials should NOT be updated by echo dedup (RTO9a3: discard without further action)") - } - - @Test - fun `(RTO9) non-echo CHANNEL message is applied normally when serial not in appliedOnAckSerials`() { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(10.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 3.0) - ), - serial = "ser-channel-01", - siteCode = "site1" - ) - - // serial NOT in appliedOnAckSerials — this is a regular (non-echo) CHANNEL message - assertFalse(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-channel-01")) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - objectsManager.handleObjectMessages(listOf(msg)) - - // Should be applied normally (CHANNEL source) - assertEquals(13.0, counter.data.get(), "counter should be incremented by CHANNEL message") - // siteTimeserials IS updated for CHANNEL source (RTLC7c) - assertEquals("ser-channel-01", counter.siteTimeserials["site1"], - "siteTimeserials should be updated for CHANNEL source") - } - - @Test - fun `(RTO22) applyAckResult waits for SYNCED state and applies with LOCAL source after endSync`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Syncing - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-ack-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - // Launch applyAckResult in background — will suspend while SYNCING - val ackJob = launch { - objectsManager.applyAckResult(listOf(msg)) - } - - // Allow the coroutine to start and reach deferred.await() - yield() - - // During SYNCING — waiter is pending, message NOT yet applied - assertNotNull(objectsManager.SyncCompletionWaiter, "sync completion should be pending during SYNCING") - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.isEmpty(), - "appliedOnAckSerials should be empty while waiting") - assertEquals(0.0, counter.data.get(), "data should not be applied while SYNCING") - - // End sync — completes waiters (schedules resume), then transitions to SYNCED - objectsManager.endSync() - ackJob.join() - - // After endSync — message applied with LOCAL source, serial tracked - assertEquals(5.0, counter.data.get(), "counter should be incremented after endSync") - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-ack-01"), - "serial should be tracked in appliedOnAckSerials after LOCAL apply") - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state) - } - - @Test - fun `(RTO5c6) endSync applies buffered CHANNEL messages then unblocks pending ACK waiters`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(10.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - val incMsg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-01", - siteCode = "site1" - ) - - // Start a new sync (state → SYNCING) - objectsManager.startNewSync(null) - assertEquals(ObjectsState.Syncing, defaultRealtimeObjects.state) - - // Suspend the ACK waiter (SYNCING) - val ackJob = launch { - objectsManager.applyAckResult(listOf(incMsg)) - } - yield() - assertNotNull(objectsManager.SyncCompletionWaiter) - - // Buffer the echo OBJECT message (also buffered since SYNCING) - objectsManager.handleObjectMessages(listOf(incMsg)) - assertEquals(1, objectsManager.BufferedObjectOperations.size) - - // End sync — applies CHANNEL buffered messages first, clears appliedOnAckSerials, then unblocks waiters - objectsManager.endSync() - ackJob.join() - - // After endSync: - // 1. CHANNEL echo applied: counter = 10 + 5 = 15; siteTimeserials["site1"] = "ser-01" - // 2. appliedOnAckSerials cleared (was empty since no LOCAL applied during sync) - // 3. Waiter resumes → LOCAL apply → canApplyOperation rejects (serial not newer) → applied=false - assertEquals(15.0, counter.data.get(), "counter should be incremented exactly once") - assertEquals("ser-01", counter.siteTimeserials["site1"], - "siteTimeserials should be updated by CHANNEL echo") - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.isEmpty(), - "appliedOnAckSerials should be empty (LOCAL apply was rejected by canApplyOperation)") - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state) - } - - @Test - fun `(RTO5c9) endSync applies buffered CHANNEL messages then clears appliedOnAckSerials`() { - val defaultRealtimeObjects = makeRealtimeObjects() - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(10.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - // Start a sync - objectsManager.startNewSync(null) - assertEquals(ObjectsState.Syncing, defaultRealtimeObjects.state) - - // Buffer a CHANNEL message during sync - val channelMsg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 3.0) - ), - serial = "ser-channel-01", - siteCode = "site1" - ) - objectsManager.handleObjectMessages(listOf(channelMsg)) - assertEquals(1, objectsManager.BufferedObjectOperations.size) - - // Simulate a serial that was somehow added during sync - defaultRealtimeObjects.appliedOnAckSerials.add("ser-during-sync") - - // End sync — CHANNEL messages applied first, then appliedOnAckSerials cleared (RTO5c9) - objectsManager.endSync() - - // CHANNEL message was applied (counter incremented) - assertEquals(13.0, counter.data.get(), - "buffered CHANNEL message should be applied by endSync") - // appliedOnAckSerials cleared at sync end (RTO5c9) - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.isEmpty(), - "appliedOnAckSerials should be cleared at sync end (RTO5c9)") - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state) - } - - @Test - fun `(RTO20e1) failBufferedAcks fails pending deferreds with error code 92008`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Syncing - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - val error = AblyException.fromErrorInfo( - ErrorInfo("channel failed while waiting for sync", 400, 92008) - ) - - var caughtException: Exception? = null - val ackJob = launch { - try { - objectsManager.applyAckResult(listOf(msg)) - } catch (e: Exception) { - caughtException = e - } - } - - // Allow the coroutine to start and suspend on deferred.await() - yield() - - // Fail the buffered ACK (RTO20e1) - objectsManager.failBufferedAcks(error) - - ackJob.join() - - assertNotNull(caughtException, "buffered ACK should fail with an exception") - val ablyEx = caughtException as? AblyException - assertNotNull(ablyEx, "exception should be an AblyException") - assertEquals(92008, ablyEx.errorInfo.code, - "error code should be 92008 (PublishAndApplyFailedDueToChannelState)") - assertEquals(400, ablyEx.errorInfo.statusCode, "status code should be 400") - } - - @Test - fun `Echo arrives before ACK - operation applied exactly once via canApplyOperation`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(10.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - // Step 1: echo arrives first as CHANNEL message — applied normally - objectsManager.handleObjectMessages(listOf(msg)) - assertEquals(15.0, counter.data.get(), "echo should be applied as CHANNEL message") - assertEquals("ser-01", counter.siteTimeserials["site1"], - "siteTimeserials should be updated by CHANNEL echo") - - // Step 2: ACK fires — applyAckResult with same serial (state is SYNCED, no suspend) - objectsManager.applyAckResult(listOf(msg)) - - // canApplyOperation rejects (serial "ser-01" is not newer than siteTimeserials["site1"] = "ser-01") - assertEquals(15.0, counter.data.get(), "counter should NOT be incremented again by late ACK apply") - // applied=false → serial NOT added to appliedOnAckSerials - assertFalse(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-01"), - "serial should NOT be in appliedOnAckSerials when LOCAL apply was rejected") - } - - @Test - fun `publishAndApply logs error and returns without apply when siteCode is null`() = runTest { - val adapter = getMockObjectsAdapter() - // Create a ConnectionManager mock with all fields needed for publish() to succeed - val cm = mockk(relaxed = true) - cm.maxMessageSize = 65536 // direct field assignment bypasses mock interception issues - every { cm.isActive } returns true - every { cm.send(any(), any(), any()) } answers { - @Suppress("UNCHECKED_CAST") - val callback = thirdArg>() - callback.onSuccess(io.ably.lib.types.PublishResult(null)) // null serials → RTO20c2 path - } - every { adapter.connectionManager } returns cm - // siteCode is null (relaxed mock default) — triggers RTO20c1 graceful degradation path - - val defaultRealtimeObjects = DefaultRealtimeObjects("testChannel", adapter).also { testInstances.add(it) } - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ) - ) - - // Should not throw even when siteCode is null (RTO20c1 graceful degradation) - defaultRealtimeObjects.publishAndApply(arrayOf(msg)) - - assertEquals(0.0, counter.data.get(), "no local apply should happen when siteCode is null") - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.isEmpty(), - "appliedOnAckSerials should be empty when siteCode is null") - } - - @Test - fun `(issue 7b) publishAndApply logs error and returns without apply when serials length mismatches`() = runTest { - val adapter = getMockObjectsAdapter() - // Create a ConnectionManager mock that returns a PublishResult with wrong-length serials - val cm = mockk(relaxed = true) - cm.maxMessageSize = 65536 // direct field assignment bypasses mock interception issues - every { cm.isActive } returns true - cm.siteCode = "site1" // direct field assignment (siteCode is a Java public field) - every { cm.send(any(), any(), any()) } answers { - @Suppress("UNCHECKED_CAST") - val callback = thirdArg>() - callback.onSuccess(io.ably.lib.types.PublishResult(arrayOfNulls(0))) // wrong length (0 instead of 1) - } - every { adapter.connectionManager } returns cm - - val defaultRealtimeObjects = DefaultRealtimeObjects("testChannel", adapter).also { testInstances.add(it) } - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ) - ) - - // Should not throw even when serials length mismatches (RTO20c2 graceful degradation) - defaultRealtimeObjects.publishAndApply(arrayOf(msg)) - - assertEquals(0.0, counter.data.get(), "no local apply should happen when serials length mismatches") - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.isEmpty(), - "appliedOnAckSerials should be empty when serials length mismatches") - } - - @Test - fun `(RTO5f2a2) partial sync map entries are merged across two messages with the same objectId`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - val msg1 = ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = mapOf("key1" to ObjectsMapEntry(data = ObjectData(string = "value1")))) - ) - ) - val msg2 = ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial2"), - map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = mapOf("key2" to ObjectsMapEntry(data = ObjectData(string = "value2")))) - ) - ) - - objectsManager.handleObjectSyncMessages(listOf(msg1, msg2), "sync-1:") - - val liveMap = defaultRealtimeObjects.objectsPool.get("map:test@1") as DefaultLiveMap - assertNotNull(liveMap.data["key1"], "key1 should be present after merge") - assertNotNull(liveMap.data["key2"], "key2 should be present after merge") - assertEquals("value1", liveMap.data["key1"]?.data?.string) - assertEquals("value2", liveMap.data["key2"]?.data?.string) - } - - @Test - fun `(RTO5f2a2) partial sync map entries merged across separate protocol messages`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - val objectId = "map:test@1" - val siteTimeserials = mapOf("site1" to "serial1") - - // Protocol message 1: first partial (has cursor → not ending) - objectsManager.handleObjectSyncMessages( - listOf( - ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = objectId, - tombstone = false, - siteTimeserials = siteTimeserials, - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf("key1" to ObjectsMapEntry(data = ObjectData(string = "value1"))) - ) - ) - ) - ), - "sync-1:cursor1" - ) - - // Protocol message 2: second partial for same objectId (has cursor → not ending) - objectsManager.handleObjectSyncMessages( - listOf( - ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = objectId, - tombstone = false, - siteTimeserials = siteTimeserials, - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf("key2" to ObjectsMapEntry(data = ObjectData(string = "value2"))) - ) - ) - ) - ), - "sync-1:cursor2" - ) - - // Protocol message 3: third partial for same objectId (empty cursor → ends sync) - objectsManager.handleObjectSyncMessages( - listOf( - ObjectMessage( - id = "msg3", - objectState = ObjectState( - objectId = objectId, - tombstone = false, - siteTimeserials = siteTimeserials, - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf("key3" to ObjectsMapEntry(data = ObjectData(string = "value3"))) - ) - ) - ) - ), - "sync-1:" // empty cursor → sync ends, applySync() runs - ) - - // Verify all 3 keys from 3 separate protocol messages are merged into the live map - val liveMap = defaultRealtimeObjects.objectsPool.get(objectId) as DefaultLiveMap - assertNotNull(liveMap.data["key1"], "key1 from first protocol message should be present") - assertNotNull(liveMap.data["key2"], "key2 from second protocol message should be present") - assertNotNull(liveMap.data["key3"], "key3 from third protocol message should be present") - assertEquals("value1", liveMap.data["key1"]?.data?.string) - assertEquals("value2", liveMap.data["key2"]?.data?.string) - assertEquals("value3", liveMap.data["key3"]?.data?.string) - } - - @Test - fun `(RTO5c1b1c) unsupported object type during sync is skipped without breaking other objects`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - mockkStatic(Log::class) - every { Log.w(any(), any()) } returns 0 - - // msg1: valid map object - val msg1 = ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf("key1" to ObjectsMapEntry(data = ObjectData(string = "value1"))) - ) - ) - ) - // msg2: unsupported type (neither counter nor map) - val msg2 = ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = "unknown:test@2", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - ) - ) - // msg3: valid counter object - val msg3 = ObjectMessage( - id = "msg3", - objectState = ObjectState( - objectId = "counter:test@3", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - counter = ObjectsCounter(count = 42.0) - ) - ) - - // Send all three in one sync — msg2 should be skipped, msg1 and msg3 should be applied - objectsManager.handleObjectSyncMessages(listOf(msg1, msg2, msg3), "sync-1:") - - val liveMap = defaultRealtimeObjects.objectsPool.get("map:test@1") as DefaultLiveMap - assertNotNull(liveMap.data["key1"], "valid map object should be created despite unsupported object in same sync") - - val counter = defaultRealtimeObjects.objectsPool.get("counter:test@3") as DefaultLiveCounter - assertEquals(42.0, counter.data.get(), "valid counter should be created despite unsupported object in same sync") - - // Unsupported object should NOT be in the pool - assertNull(defaultRealtimeObjects.objectsPool.get("unknown:test@2"), "unsupported object type should not be in pool") - } - - @Test - fun `(RTO5f2a1) tombstone on second partial message replaces pool entry entirely`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - val msg1 = ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = mapOf("key1" to ObjectsMapEntry(data = ObjectData(string = "value1")))) - ) - ) - val msg2 = ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = true, - siteTimeserials = mapOf("site1" to "serial2"), - map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = emptyMap()) - ) - ) - - objectsManager.handleObjectSyncMessages(listOf(msg1, msg2), "sync-1:") - - val liveMap = defaultRealtimeObjects.objectsPool.get("map:test@1") as DefaultLiveMap - // After tombstone replaces the entry, the map should have no key1 - assertNull(liveMap.data["key1"], "key1 should not be present after tombstone replaced the pool entry") - } - - @Test - fun `(RTO5f2b) partial sync counter message logs error and is skipped`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - mockkStatic(Log::class) - every { Log.e(any(), any()) } returns 0 - - val msg1 = ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = "counter:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - counter = ObjectsCounter(count = 10.0) - ) - ) - val msg2 = ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = "counter:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial2"), - counter = ObjectsCounter(count = 5.0) - ) - ) - - objectsManager.handleObjectSyncMessages(listOf(msg1, msg2), "sync-1:") - - // Pool should contain only msg1 (msg2 skipped) - val counter = defaultRealtimeObjects.objectsPool.get("counter:test@1") as DefaultLiveCounter - assertEquals(10.0, counter.data.get(), "counter value should be from msg1 only (msg2 skipped)") - verify { Log.e(any(), match { it.contains("partial sync message for a counter") }) } - } - - @Test - fun `(RTO5f2c) partial sync message with unsupported type logs warning and is skipped`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - mockkStatic(Log::class) - every { Log.w(any(), any()) } returns 0 - - val msg1 = ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = mapOf("key1" to ObjectsMapEntry(data = ObjectData(string = "value1")))) - ) - ) - // msg2 has neither map nor counter — hits the else branch (RTO5f2c) - val msg2 = ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial2"), - ) - ) - - objectsManager.handleObjectSyncMessages(listOf(msg1, msg2), "sync-1:") - - // Pool entry should still be msg1 (msg2 was skipped) - val liveMap = defaultRealtimeObjects.objectsPool.get("map:test@1") as DefaultLiveMap - assertNotNull(liveMap.data["key1"], "key1 should still be present (msg2 skipped)") - verify { Log.w(any(), match { it.contains("unsupported object type") }) } - } - - private fun mockZeroValuedObjects() { - mockkObject(DefaultLiveMap.Companion) - every { - DefaultLiveMap.zeroValue(any(), any()) - } answers { - mockk(relaxed = true) - } - mockkObject(DefaultLiveCounter.Companion) - every { - DefaultLiveCounter.zeroValue(any(), any()) - } answers { - mockk(relaxed = true) - } - } - - @AfterTest - fun tearDown() { - val cleanupError = AblyException.fromErrorInfo(ErrorInfo("test cleanup", 500)) - testInstances.forEach { it.dispose(cleanupError) } - testInstances.clear() - unmockkAll() // Clean up all mockk objects after each test - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt deleted file mode 100644 index aff4f9d1a..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -package io.ably.lib.objects.unit.objects - -import io.ably.lib.objects.DefaultRealtimeObjects -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ROOT_OBJECT_ID -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.type.livemap.LiveMapEntry -import io.ably.lib.objects.unit.* -import io.mockk.mockk -import io.mockk.spyk -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class ObjectsPoolTest { - - @Test - fun `(RTO3, RTO3a, RTO3b) An internal ObjectsPool should be used to maintain the list of objects present on a channel`() { - val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", getMockObjectsAdapter()) - val objectsPool = defaultRealtimeObjects.objectsPool - assertNotNull(objectsPool) - - // RTO3b - It must always contain a LiveMap object with id root - val rootLiveMap = objectsPool.get(ROOT_OBJECT_ID) - assertNotNull(rootLiveMap) - assertTrue(rootLiveMap is DefaultLiveMap) - assertTrue(rootLiveMap.data.isEmpty()) - assertEquals(ROOT_OBJECT_ID, rootLiveMap.objectId) - assertEquals(1, objectsPool.size(), "RTO3 - Should only contain the root object initially") - - // RTO3a - ObjectsPool is a Dict, a map of RealtimeObjects keyed by objectId string - val testLiveMap = DefaultLiveMap.zeroValue("map:testObject@1", mockk(relaxed = true)) - objectsPool.set("map:testObject@1", testLiveMap) - val testLiveCounter = DefaultLiveCounter.zeroValue("counter:testObject@1", mockk(relaxed = true)) - objectsPool.set("counter:testObject@1", testLiveCounter) - // Assert that the objects are stored in the pool - assertEquals(testLiveMap, objectsPool.get("map:testObject@1")) - assertEquals(testLiveCounter, objectsPool.get("counter:testObject@1")) - assertEquals(3, objectsPool.size(), "RTO3 - Should have 3 objects in pool (root + testLiveMap + testLiveCounter)") - } - - @Test - fun `(RTO6) ObjectsPool should create zero-value objects if not exists`() { - val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", getMockObjectsAdapter()) - val objectsPool = spyk(defaultRealtimeObjects.objectsPool) - assertEquals(1, objectsPool.size(), "RTO3 - Should only contain the root object initially") - - // Test creating zero-value map - // RTO6b1, RTO6b2 - Type is parsed from the objectId format (map:hash@timestamp) - val mapId = "map:xyz789@67890" - val map = objectsPool.createZeroValueObjectIfNotExists(mapId) - assertNotNull(map, "Should create a map object") - assertTrue(map is DefaultLiveMap, "RTO6b2 - Should create a LiveMap for map type") - assertEquals(mapId, map.objectId) - assertTrue(map.data.isEmpty(), "RTO6b2 - Should create an empty map") - assertEquals(2, objectsPool.size(), "RTO6 - root + map should be in pool after creation") - - // Test creating zero-value counter - // RTO6b1, RTO6b3 - Type is parsed from the objectId format (counter:hash@timestamp) - val counterId = "counter:abc123@12345" - val counter = objectsPool.createZeroValueObjectIfNotExists(counterId) - assertNotNull(counter, "Should create a counter object") - assertTrue(counter is DefaultLiveCounter, "RTO6b3 - Should create a LiveCounter for counter type") - assertEquals(counterId, counter.objectId) - assertEquals(0.0, counter.data.get(), "RTO6b3 - Should create a zero-value counter") - assertEquals(3, objectsPool.size(), "RTO6 - root + map + counter should be in pool after creation") - - // RTO6a - If object exists in pool, do not create a new one - val existingMap = objectsPool.createZeroValueObjectIfNotExists(mapId) - assertEquals(map, existingMap, "RTO6a - Should return existing object, not create a new one") - val existingCounter = objectsPool.createZeroValueObjectIfNotExists(counterId) - assertEquals(counter, existingCounter, "RTO6a - Should return existing object, not create a new one") - assertEquals(3, objectsPool.size(), "RTO6 - Should still have 3 objects in pool after re-creation attempt") - } - - @Test - fun `(RTO4b1, RTO4b2) ObjectsPool should reset to initial pool retaining original root map`() { - val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", getMockObjectsAdapter()) - val objectsPool = defaultRealtimeObjects.objectsPool - assertEquals(1, objectsPool.size()) - val rootMap = objectsPool.get(ROOT_OBJECT_ID) as DefaultLiveMap - // add some data to the root map - rootMap.data["initialKey1"] = LiveMapEntry(data = ObjectData("testValue1")) - rootMap.data["initialKey2"] = LiveMapEntry(data = ObjectData("testValue2")) - assertEquals(2, rootMap.data.size, "RTO3 - Root map should have initial data") - - // Add some objects - objectsPool.set("counter:testObject@1", DefaultLiveCounter.zeroValue("counter:testObject@1", mockk(relaxed = true))) - assertEquals(2, objectsPool.size()) // root + testObject - objectsPool.set("counter:testObject@2", DefaultLiveCounter.zeroValue("counter:testObject@2", mockk(relaxed = true))) - assertEquals(3, objectsPool.size()) // root + testObject + anotherObject - objectsPool.set("map:testObject@1", DefaultLiveMap.zeroValue("map:testObject@1", mockk(relaxed = true))) - assertEquals(4, objectsPool.size()) // root + testObject + anotherObject + testMap - - // Reset to initial pool - objectsPool.resetToInitialPool(true) - - // RTO4b1 - Should only contain root object - assertEquals(1, objectsPool.size()) - assertEquals(rootMap, objectsPool.get(ROOT_OBJECT_ID)) - // RTO4b2 - RootMap should be empty after reset - assertTrue(rootMap.data.isEmpty(), "RTO3 - Root map should be empty after reset") - } - - @Test - fun `(RTO5c2, RTO5c2a) ObjectsPool should delete extra object IDs`() { - val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", getMockObjectsAdapter()) - val objectsPool = defaultRealtimeObjects.objectsPool - - // Add some objects - objectsPool.set("counter:testObject@1", DefaultLiveCounter.zeroValue("counter:testObject@1", mockk(relaxed = true))) - objectsPool.set("counter:testObject@2", DefaultLiveCounter.zeroValue("counter:testObject@2", mockk(relaxed = true))) - objectsPool.set("counter:testObject@3", DefaultLiveCounter.zeroValue("counter:testObject@3", mockk(relaxed = true))) - assertEquals(4, objectsPool.size()) // root + 3 objects - - // Delete extra object IDs (keep only object1 and object2) - val receivedObjectIds = mutableSetOf("counter:testObject@1", "counter:testObject@2") - objectsPool.deleteExtraObjectIds(receivedObjectIds) - - // Should only contain root, object1, and object2 - assertEquals(3, objectsPool.size()) - // RTO5c2a - Should keep the root object - assertNotNull(objectsPool.get(ROOT_OBJECT_ID)) - // RTO5c2 - Should delete object3 and keep object1 and object2 - assertNotNull(objectsPool.get("counter:testObject@1")) - assertNotNull(objectsPool.get("counter:testObject@2")) - assertNull(objectsPool.get("counter:testObject@3")) // Should be deleted - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseRealtimeObjectTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseRealtimeObjectTest.kt deleted file mode 100644 index 9868bf680..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseRealtimeObjectTest.kt +++ /dev/null @@ -1,172 +0,0 @@ -package io.ably.lib.objects.unit.type - -import io.ably.lib.objects.* -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.unit.getDefaultRealtimeObjectsWithMockedDeps -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlin.test.assertFailsWith - -class BaseRealtimeObjectTest { - - private val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - @Test - fun `(RTLO1, RTLO2) BaseRealtimeObject should be abstract base class for LiveMap and LiveCounter`() { - // RTLO2 - Check that BaseRealtimeObject is abstract - val isAbstract = java.lang.reflect.Modifier.isAbstract(BaseRealtimeObject::class.java.modifiers) - assertTrue(isAbstract, "BaseRealtimeObject should be an abstract class") - - // RTLO1 - Check that BaseRealtimeObject is the parent class of DefaultLiveMap and DefaultLiveCounter - assertTrue(BaseRealtimeObject::class.java.isAssignableFrom(DefaultLiveMap::class.java), - "DefaultLiveMap should extend BaseRealtimeObject") - assertTrue(BaseRealtimeObject::class.java.isAssignableFrom(DefaultLiveCounter::class.java), - "DefaultLiveCounter should extend BaseRealtimeObject") - } - - @Test - fun `(RTLO3) BaseRealtimeObject should have required properties`() { - val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) - val liveCounter: BaseRealtimeObject = DefaultLiveCounter.zeroValue("counter:testObject@1", defaultRealtimeObjects) - // RTLO3a - check that objectId is set correctly - assertEquals("map:testObject@1", liveMap.objectId) - assertEquals("counter:testObject@1", liveCounter.objectId) - - // RTLO3b, RTLO3b1 - check that siteTimeserials is initialized as an empty map - assertEquals(emptyMap(), liveMap.siteTimeserials) - assertEquals(emptyMap(), liveCounter.siteTimeserials) - - // RTLO3c - Create operation merged flag - assertFalse(liveMap.createOperationIsMerged, "Create operation should not be merged by default") - assertFalse(liveCounter.createOperationIsMerged, "Create operation should not be merged by default") - } - - @Test - fun `(RTLO4a1, RTLO4a2) canApplyOperation should accept ObjectMessage params and return boolean`() { - // RTLO4a1a - Assert parameter types and return type based on method signature using reflection - val method = BaseRealtimeObject::class.java.findMethod("canApplyOperation") - - // RTLO4a1a - Verify parameter types - val parameters = method.parameters - assertEquals(2, parameters.size, "canApplyOperation should have exactly 2 parameters") - - // First parameter should be String? (siteCode) - assertEquals(String::class.java, parameters[0].type, "First parameter should be of type String?") - assertTrue(parameters[0].isVarArgs.not(), "First parameter should not be varargs") - - // Second parameter should be String? (timeSerial) - assertEquals(String::class.java, parameters[1].type, "Second parameter should be of type String?") - assertTrue(parameters[1].isVarArgs.not(), "Second parameter should not be varargs") - - // RTLO4a2 - Verify return type - assertEquals(Boolean::class.java, method.returnType, "canApplyOperation should return Boolean") - } - - @Test - fun `(RTLO4a3) canApplyOperation should throw error for null or empty incoming siteSerial`() { - val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) - - // Test null serial - assertFailsWith("Should throw error for null serial") { - liveMap.canApplyOperation("site1", null) - } - - // Test empty serial - assertFailsWith("Should throw error for empty serial") { - liveMap.canApplyOperation("site1", "") - } - - // Test null siteCode - assertFailsWith("Should throw error for null site code") { - liveMap.canApplyOperation(null, "serial1") - } - - // Test empty siteCode - assertFailsWith("Should throw error for empty site code") { - liveMap.canApplyOperation("", "serial1") - } - } - - @Test - fun `(RTLO4a4, RTLO4a5) canApplyOperation should return true when existing siteSerial is null or empty`() { - val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) - assertTrue(liveMap.siteTimeserials.isEmpty(), "Initial siteTimeserials should be empty") - - // RTLO4a4 - Get siteSerial from siteTimeserials map - // RTLO4a5 - Return true when siteSerial is null (no entry in map) - assertTrue(liveMap.canApplyOperation("site1", "serial1"), - "Should return true when no siteSerial exists for the site") - - // RTLO4a5 - Return true when siteSerial is empty string - liveMap.siteTimeserials["site1"] = "" - assertTrue(liveMap.canApplyOperation("site1", "serial1"), - "Should return true when siteSerial is empty string") - } - - @Test - fun `(RTLO4a6) canApplyOperation should return true when message siteSerial is greater than existing siteSerial`() { - val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) - - // Set existing siteSerial - liveMap.siteTimeserials["site1"] = "serial1" - - // RTLO4a6 - Return true when message serial is greater (lexicographically) - assertTrue(liveMap.canApplyOperation("site1", "serial2"), - "Should return true when message serial 'serial2' > siteSerial 'serial1'") - - assertTrue(liveMap.canApplyOperation("site1", "serial10"), - "Should return true when message serial 'serial10' > siteSerial 'serial1'") - - assertTrue(liveMap.canApplyOperation("site1", "serialA"), - "Should return true when message serial 'serialA' > siteSerial 'serial1'") - } - - @Test - fun `(RTLO4a6) canApplyOperation should return false when message siteSerial is less than or equal to siteSerial`() { - val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) - - // Set existing siteSerial - liveMap.siteTimeserials["site1"] = "serial2" - - // RTLO4a6 - Return false when message serial is less than siteSerial - assertFalse(liveMap.canApplyOperation("site1", "serial1"), - "Should return false when message serial 'serial1' < siteSerial 'serial2'") - - // RTLO4a6 - Return false when message serial equals siteSerial - assertFalse(liveMap.canApplyOperation("site1", "serial2"), - "Should return false when message serial equals siteSerial") - - // RTLO4a6 - Return false when message serial is less (lexicographically) - assertTrue(liveMap.canApplyOperation("site1", "serialA"), - "Should return true when message serial 'serialA' > siteSerial 'serial2'") - } - - @Test - fun `(RTLO4a) canApplyOperation should work with different site codes`() { - val liveMap: BaseRealtimeObject = DefaultLiveCounter.zeroValue("counter:testObject@1", defaultRealtimeObjects) - - // Set serials for different sites - liveMap.siteTimeserials["site1"] = "serial1" - liveMap.siteTimeserials["site2"] = "serial5" - - // Test site1 - assertTrue(liveMap.canApplyOperation("site1", "serial2"), - "Should return true for site1 when serial2 > serial1") - assertFalse(liveMap.canApplyOperation("site1", "serial1"), - "Should return false for site1 when serial1 = serial1") - - // Test site2 - assertTrue(liveMap.canApplyOperation("site2", "serial6"), - "Should return true for site2 when serial6 > serial5") - assertFalse(liveMap.canApplyOperation("site2", "serial4"), - "Should return false for site2 when serial4 < serial5") - - // Test new site (should return true) - assertTrue(liveMap.canApplyOperation("site3", "serial1"), - "Should return true for new site with any serial") - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt deleted file mode 100644 index 3e82cebc9..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt +++ /dev/null @@ -1,262 +0,0 @@ -package io.ably.lib.objects.unit.type.livecounter - -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectsOperationSource -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.unit.getDefaultLiveCounterWithMockedDeps -import io.ably.lib.types.AblyException -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class DefaultLiveCounterTest { - @Test - fun `(RTLC6, RTLC6a) DefaultLiveCounter should override serials with state serials from sync`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - // Set initial data - liveCounter.siteTimeserials["site1"] = "serial1" - liveCounter.siteTimeserials["site2"] = "serial2" - - val objectState = ObjectState( - objectId = "counter:testCounter@1", - siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), - tombstone = false, - ) - - val objectMessage = ObjectMessage( - id = "testId", - objectState = objectState, - serial = "serial1", - siteCode = "site1" - ) - - liveCounter.applyObjectSync(objectMessage) - assertEquals(mapOf("site3" to "serial3", "site4" to "serial4"), liveCounter.siteTimeserials) // RTLC6a - } - - @Test - fun `(RTLC7, RTLC7a) DefaultLiveCounter should check objectId before applying operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "counter:testCounter@2", // Different objectId - counterCreate = CounterCreate(count = 20.0) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7a - Should throw error when objectId doesn't match - val exception = assertFailsWith { - liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - } - val errorInfo = exception.errorInfo - assertNotNull(errorInfo) - - // Assert on error codes - assertEquals(92000, exception.errorInfo?.code) // InvalidObject error code - assertEquals(500, exception.errorInfo?.statusCode) // InternalServerError status code - } - - @Test - fun `(RTLC7, RTLC7b) DefaultLiveCounter should validate site serial before applying operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - // Set existing site serial that is newer than the incoming message - liveCounter.siteTimeserials["site1"] = "serial2" // Newer than "serial1" - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "counter:testCounter@1", // Matching objectId - counterCreate = CounterCreate(count = 20.0) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial1", // Older serial - siteCode = "site1" - ) - - // RTLC7b - Should skip operation when serial is not newer - liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - // Verify that the site serial was not updated (operation was skipped) - assertEquals("serial2", liveCounter.siteTimeserials["site1"]) - } - - @Test - fun `(RTLC7, RTLC7c) DefaultLiveCounter should update site serial if valid`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - // Set existing site serial that is older than the incoming message - liveCounter.siteTimeserials["site1"] = "serial1" // Older than "serial2" - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "counter:testCounter@1", // Matching objectId - counterCreate = CounterCreate(count = 20.0) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial2", // Newer serial - siteCode = "site1" - ) - - // RTLC7c - Should update site serial when operation is valid - liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - // Verify that the site serial was updated - assertEquals("serial2", liveCounter.siteTimeserials["site1"]) - } - - @Test - fun `(RTLC7c LOCAL) applyObject with LOCAL source updates data but does NOT update siteTimeserials`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - assertTrue(liveCounter.siteTimeserials.isEmpty(), "siteTimeserials should start empty") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:testCounter@1", - counterInc = io.ably.lib.objects.CounterInc(number = 5.0) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7c - LOCAL source: data IS updated, siteTimeserials is NOT updated - val result = liveCounter.applyObject(message, ObjectsOperationSource.LOCAL) - - assertTrue(result, "applyObject should return true for successful COUNTER_INC") - assertEquals(5.0, liveCounter.data.get(), "data should be updated for LOCAL source") - assertFalse(liveCounter.siteTimeserials.containsKey("site1"), - "siteTimeserials should NOT be updated for LOCAL source") - } - - @Test - fun `(RTLC7b return) applyObject returns false when incoming serial is not newer than existing`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - liveCounter.siteTimeserials["site1"] = "serial5" // Newer than incoming "serial1" - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:testCounter@1", - counterInc = io.ably.lib.objects.CounterInc(number = 5.0) - ), - serial = "serial1", // Older than "serial5" - siteCode = "site1" - ) - - // RTLC7b - Should return false when canApplyOperation fails - val result = liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertFalse(result, "applyObject should return false when serial is not newer") - assertEquals(0.0, liveCounter.data.get(), "data should not be changed") - assertEquals("serial5", liveCounter.siteTimeserials["site1"], "siteTimeserials should not change") - } - - @Test - fun `(RTLC7e return) applyObject returns false when object is tombstoned`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - liveCounter.tombstone(null) // Tombstone the object - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:testCounter@1", - counterInc = io.ably.lib.objects.CounterInc(number = 5.0) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7e - Should return false when object is tombstoned - val result = liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertFalse(result, "applyObject should return false when object is tombstoned") - } - - @Test - fun `(RTLC7d2b) applyObject returns true for successful COUNTER_INC`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:testCounter@1", - counterInc = io.ably.lib.objects.CounterInc(number = 5.0) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7d2b - Should return true for successful COUNTER_INC - val result = liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for successful COUNTER_INC") - assertEquals(5.0, liveCounter.data.get()) - } - - @Test - fun `(RTLC7d1b) applyObject returns true for successful COUNTER_CREATE`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "counter:testCounter@1", - counterCreate = CounterCreate(count = 20.0) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7d1b - Should return true for successful COUNTER_CREATE - val result = liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for successful COUNTER_CREATE") - } - - @Test - fun `(RTLC7d4b) applyObject returns true for OBJECT_DELETE (tombstone)`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.ObjectDelete, - objectId = "counter:testCounter@1", - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7d4b - Should return true for OBJECT_DELETE (tombstone applied) - val result = liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for OBJECT_DELETE") - assertTrue(liveCounter.isTombstoned, "object should be tombstoned") - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt deleted file mode 100644 index e7dda488f..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt +++ /dev/null @@ -1,356 +0,0 @@ -package io.ably.lib.objects.unit.type.livecounter - -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.unit.LiveCounterManager -import io.ably.lib.objects.unit.TombstonedAt -import io.ably.lib.objects.unit.getDefaultLiveCounterWithMockedDeps -import io.ably.lib.types.AblyException -import org.junit.Test -import kotlin.test.* - -class DefaultLiveCounterManagerTest { - - @Test - fun `(RTLC6, RTLC6b, RTLC6c) DefaultLiveCounter should override counter data with state from sync`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val objectState = ObjectState( - objectId = "testCounterId", - counter = ObjectsCounter(count = 25.0), - siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), - tombstone = false, - ) - - val update = liveCounterManager.applyState(objectState, null) - - assertFalse(liveCounter.createOperationIsMerged) // RTLC6b - assertEquals(25.0, liveCounter.data.get()) // RTLC6c - assertEquals(15.0, update.update.amount) // Difference between old and new data - } - - - @Test - fun `(RTLC6, RTLC6d) DefaultLiveCounter should merge create operation in state from sync`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(5.0) - - val createOp = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = CounterCreate(count = 10.0) - ) - - val objectState = ObjectState( - objectId = "testCounterId", - counter = ObjectsCounter(count = 15.0), - createOp = createOp, - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - // RTLC6d - Merge initial data from create operation - val update = liveCounterManager.applyState(objectState, null) - - assertEquals(25.0, liveCounter.data.get()) // 15 from state + 10 from create op - assertEquals(20.0, update.update.amount) // Total change - } - - - @Test - fun `(RTLC7d1b) LiveCounterManager applyOperation returns true for COUNTER_CREATE`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = CounterCreate(count = 10.0) - ) - - // RTLC7d1b - Should return true for successful COUNTER_CREATE - val result = liveCounterManager.applyOperation(operation, null) - assertTrue(result, "applyOperation should return true for COUNTER_CREATE") - } - - @Test - fun `(RTLC7d2b) LiveCounterManager applyOperation returns true for COUNTER_INC`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "testCounterId", - counterInc = CounterInc(number = 5.0) - ) - - // RTLC7d2b - Should return true for successful COUNTER_INC - val result = liveCounterManager.applyOperation(operation, null) - assertTrue(result, "applyOperation should return true for COUNTER_INC") - } - - @Test - fun `(RTLC7d4b) LiveCounterManager applyOperation returns true for OBJECT_DELETE`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.ObjectDelete, - objectId = "testCounterId", - ) - - // RTLC7d4b - Should return true for OBJECT_DELETE (tombstone) - val result = liveCounterManager.applyOperation(operation, null) - assertTrue(result, "applyOperation should return true for OBJECT_DELETE") - assertTrue(liveCounter.isTombstoned, "counter should be tombstoned after ObjectDelete") - } - - @Test - fun `(RTLC7, RTLC7d3) LiveCounterManager should return false for unsupported action`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, // Unsupported action for counter - objectId = "testCounterId", - mapCreate = MapCreate(semantics = ObjectsMapSemantics.LWW, entries = emptyMap()) - ) - - // RTLC7d3 - Should return false for unsupported action (no longer throws) - val result = liveCounterManager.applyOperation(operation, null) - assertFalse(result, "Should return false for unsupported action") - } - - @Test - fun `(RTLC7, RTLC7d1, RTLC8) LiveCounterManager should apply counter create operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = CounterCreate(count = 20.0) - ) - - // RTLC7d1 - Apply counter create operation - liveCounterManager.applyOperation(operation, null) - - assertEquals(20.0, liveCounter.data.get()) // Should be set to counter count - assertTrue(liveCounter.createOperationIsMerged) // Should be marked as merged - } - - @Test - fun `(RTLC8, RTLC8b) LiveCounterManager should skip counter create operation if already merged`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - liveCounter.data.set(4.0) // Start with 4 - - // Set create operation as already merged - liveCounter.createOperationIsMerged = true - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = CounterCreate(count = 20.0) - ) - - // RTLC8b - Should skip if already merged - liveCounterManager.applyOperation(operation, null) - - assertEquals(4.0, liveCounter.data.get()) // Should not change (still 0) - assertTrue(liveCounter.createOperationIsMerged) // Should remain merged - } - - @Test - fun `(RTLC8, RTLC8c) LiveCounterManager should apply counter create operation if not merged`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - // Set initial data - liveCounter.data.set(10.0) // Start with 10 - - // Set create operation as not merged - liveCounter.createOperationIsMerged = false - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = CounterCreate(count = 20.0) - ) - - // RTLC8c - Should apply if not merged - liveCounterManager.applyOperation(operation, null) - assertTrue(liveCounter.createOperationIsMerged) // Should be marked as merged - - assertEquals(30.0, liveCounter.data.get()) // Should be set to counter count - assertTrue(liveCounter.createOperationIsMerged) // RTLC16b - Should be marked as merged - } - - @Test - fun `(RTLC8, RTLC16) LiveCounterManager should handle null count in create operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = null // No count specified - ) - - // RTLC16a - Should default to 0 - liveCounterManager.applyOperation(operation, null) - - assertEquals(10.0, liveCounter.data.get()) // No change (null defaults to 0) - assertTrue(liveCounter.createOperationIsMerged) // RTLC16b - } - - @Test - fun `(RTLC7, RTLC7d2, RTLC9) LiveCounterManager should apply counter increment operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "testCounterId", - counterInc = CounterInc(number = 5.0) - ) - - // RTLC7d2 - Apply counter increment operation - liveCounterManager.applyOperation(operation, null) - - assertEquals(15.0, liveCounter.data.get()) // RTLC9f - 10 + 5 - } - - @Test - fun `(RTLC7, RTLC7d2) LiveCounterManager should throw error for missing payload for counter increment operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "testCounterId", - counterInc = null // Missing payload - ) - - // RTLC7d2 - Should throw error for missing payload - val exception = assertFailsWith { - liveCounterManager.applyOperation(operation, null) - } - - val errorInfo = exception.errorInfo - assertNotNull(errorInfo) - assertEquals(92000, errorInfo.code) // InvalidObject error code - assertEquals(500, errorInfo.statusCode) // InternalServerError status code - } - - - @Test - fun `(RTLC9, RTLC9f) LiveCounterManager should apply counter increment operation correctly`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val counterInc = CounterInc(number = 7.0) - - // RTLC9f - Apply counter increment - liveCounterManager.applyOperation(ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "testCounterId", - counterInc = counterInc - ), null) - - assertEquals(17.0, liveCounter.data.get()) // 10 + 7 - } - - @Test - fun `(RTLC7, RTLC7d2) LiveCounterManager should throw error when counterInc payload missing`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - // RTLC7d2 - Apply counter increment with no payload - throws error - val exception = assertFailsWith { - liveCounterManager.applyOperation(ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "testCounterId", - counterInc = null - ), null) - } - assertNotNull(exception.errorInfo) - assertEquals(92000, exception.errorInfo.code) - } - - @Test - fun `(RTLC6, OM2j) DefaultLiveCounter should handle tombstone with serialTimestamp in state`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val expectedTimestamp = 1234567890L - val objectState = ObjectState( - objectId = "testCounterId", - counter = null, // Null counter for tombstone - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = true, // Object is tombstoned - ) - - val update = liveCounterManager.applyState(objectState, expectedTimestamp) - - assertTrue(liveCounter.isTombstoned) // Should be tombstoned - assertEquals(expectedTimestamp, liveCounter.TombstonedAt) // Should use provided timestamp - assertEquals(0.0, liveCounter.data.get()) // Should be reset after tombstone - - // Assert on update field - should show the change - assertEquals(-10.0, update.update.amount) // Difference from 10.0 to 0.0 - } - - @Test - fun `(RTLC6, OM2j) DefaultLiveCounter should handle tombstone without serialTimestamp in state`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val objectState = ObjectState( - objectId = "testCounterId", - counter = null, // Null counter for tombstone - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = true, // Object is tombstoned - ) - - val beforeOperation = System.currentTimeMillis() - val update = liveCounterManager.applyState(objectState, null) - val afterOperation = System.currentTimeMillis() - - assertTrue(liveCounter.isTombstoned) // Should be tombstoned - assertNotNull(liveCounter.TombstonedAt) // Should have timestamp - assertTrue(liveCounter.TombstonedAt!! >= beforeOperation) // Should be after operation start - assertTrue(liveCounter.TombstonedAt!! <= afterOperation) // Should be before operation end - assertEquals(0.0, liveCounter.data.get()) // Should be reset after tombstone - - // Assert on update field - should show the change - assertEquals(-10.0, update.update.amount) // Difference from 10.0 to 0.0 - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt deleted file mode 100644 index 7ddd43937..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt +++ /dev/null @@ -1,276 +0,0 @@ -package io.ably.lib.objects.unit.type.livemap - -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectsMap -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.ObjectsOperationSource -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.unit.* -import io.ably.lib.types.AblyException -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class DefaultLiveMapTest { - @Test - fun `(RTLM6, RTLM6a) DefaultLiveMap should override serials with state serials from sync`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - // Set initial data - liveMap.siteTimeserials["site1"] = "serial1" - liveMap.siteTimeserials["site2"] = "serial2" - - val objectState = ObjectState( - objectId = "map:testMap@1", - siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), - tombstone = false, - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - ) - ) - - val objectMessage = ObjectMessage( - id = "testId", - objectState = objectState, - serial = "serial1", - siteCode = "site1" - ) - - liveMap.applyObjectSync(objectMessage) - assertEquals(mapOf("site3" to "serial3", "site4" to "serial4"), liveMap.siteTimeserials) // RTLM6a - } - - @Test - fun `(RTLM15, RTLM15a) DefaultLiveMap should check objectId before applying operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@2", // Different objectId - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap() - ) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15a - Should throw error when objectId doesn't match - val exception = assertFailsWith { - liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - } - val errorInfo = exception.errorInfo - assertNotNull(errorInfo) - - // Assert on error codes - assertEquals(92000, exception.errorInfo?.code) // InvalidObject error code - assertEquals(500, exception.errorInfo?.statusCode) // InternalServerError status code - } - - @Test - fun `(RTLM15, RTLM15b) DefaultLiveMap should validate site serial before applying operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - // Set existing site serial that is newer than the incoming message - liveMap.siteTimeserials["site1"] = "serial2" // Newer than "serial1" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", // Matching objectId - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap() - ) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial1", // Older serial - siteCode = "site1" - ) - - // RTLM15b - Should skip operation when serial is not newer - liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - // Verify that the site serial was not updated (operation was skipped) - assertEquals("serial2", liveMap.siteTimeserials["site1"]) - } - - @Test - fun `(RTLM15, RTLM15c) DefaultLiveMap should update site serial if valid`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - // Set existing site serial that is older than the incoming message - liveMap.siteTimeserials["site1"] = "serial1" // Older than "serial2" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", // Matching objectId - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap() - ) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial2", // Newer serial - siteCode = "site1" - ) - - // RTLM15c - Should update site serial when operation is valid - liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - // Verify that the site serial was updated - assertEquals("serial2", liveMap.siteTimeserials["site1"]) - } - - @Test - fun `(RTLM15c LOCAL) applyObject with LOCAL source updates data but does NOT update siteTimeserials`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - assertTrue(liveMap.siteTimeserials.isEmpty(), "siteTimeserials should start empty") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = io.ably.lib.objects.MapSet(key = "key1", value = io.ably.lib.objects.ObjectData(string = "value1")) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15c - LOCAL source: data IS updated (entry set), siteTimeserials is NOT updated - val result = liveMap.applyObject(message, ObjectsOperationSource.LOCAL) - - assertTrue(result, "applyObject should return true for successful MAP_SET") - assertEquals("value1", liveMap.data["key1"]?.data?.string, "map entry should be updated for LOCAL source") - assertFalse(liveMap.siteTimeserials.containsKey("site1"), - "siteTimeserials should NOT be updated for LOCAL source") - } - - @Test - fun `(RTLM15b return) applyObject returns false when incoming serial is not newer than existing`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - liveMap.siteTimeserials["site1"] = "serial5" // Newer than incoming "serial1" - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = io.ably.lib.objects.MapSet(key = "key1", value = io.ably.lib.objects.ObjectData(string = "value1")) - ), - serial = "serial1", // Older than "serial5" - siteCode = "site1" - ) - - // RTLM15b - Should return false when canApplyOperation fails - val result = liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertFalse(result, "applyObject should return false when serial is not newer") - assertEquals("serial5", liveMap.siteTimeserials["site1"], "siteTimeserials should not change") - } - - @Test - fun `(RTLM15e return) applyObject returns false when object is tombstoned`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - liveMap.tombstone(null) // Tombstone the object - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = io.ably.lib.objects.MapSet(key = "key1", value = io.ably.lib.objects.ObjectData(string = "value1")) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15e - Should return false when object is tombstoned - val result = liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertFalse(result, "applyObject should return false when object is tombstoned") - } - - @Test - fun `(RTLM15d2b) applyObject returns true for successful MAP_SET`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = io.ably.lib.objects.MapSet(key = "key1", value = io.ably.lib.objects.ObjectData(string = "value1")) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15d2b - Should return true for successful MAP_SET - val result = liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for successful MAP_SET") - assertEquals("value1", liveMap.data["key1"]?.data?.string) - } - - @Test - fun `(RTLM15d3b) applyObject returns true for successful MAP_REMOVE`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = io.ably.lib.objects.MapRemove(key = "key1") - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15d3b - Should return true for successful MAP_REMOVE - val result = liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for successful MAP_REMOVE") - } - - @Test - fun `(RTLM15d5b) applyObject returns true for OBJECT_DELETE (tombstone)`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.ObjectDelete, - objectId = "map:testMap@1", - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15d5b - Should return true for OBJECT_DELETE (tombstone applied) - val result = liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for OBJECT_DELETE") - assertTrue(liveMap.isTombstoned, "object should be tombstoned") - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt deleted file mode 100644 index adaf4eb81..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt +++ /dev/null @@ -1,1388 +0,0 @@ -package io.ably.lib.objects.unit.type.livemap - -import io.ably.lib.objects.* -import io.ably.lib.objects.MapClear -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.type.livemap.LiveMapEntry -import io.ably.lib.objects.type.livemap.LiveMapManager -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.objects.unit.LiveMapManager -import io.ably.lib.objects.unit.TombstonedAt -import io.ably.lib.objects.unit.getDefaultLiveMapWithMockedDeps -import io.ably.lib.types.AblyException -import io.mockk.mockk -import org.junit.Test -import org.junit.Assert.* -import kotlin.test.* - -class LiveMapManagerTest { - - private val livemapManager = LiveMapManager(mockk(relaxed = true)) - - @Test - fun `(RTLM6, RTLM6b, RTLM6c) DefaultLiveMap should override map data with state from sync`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "newValue1"), - timeserial = "serial1" - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "value2"), - timeserial = "serial2" - ) - ) - ), - siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), - tombstone = false, - ) - - val update = liveMapManager.applyState(objectState, null) - - assertFalse(liveMap.createOperationIsMerged) // RTLM6b - assertEquals(2, liveMap.data.size) // RTLM6c - assertEquals("newValue1", liveMap.data["key1"]?.data?.string) // RTLM6c - assertEquals("value2", liveMap.data["key2"]?.data?.string) // RTLM6c - - // Assert on update field - should show changes from old to new state - val expectedUpdate = mapOf( - "key1" to LiveMapUpdate.Change.UPDATED, // key1 was updated from "oldValue" to "newValue1" - "key2" to LiveMapUpdate.Change.UPDATED // key2 was added - ) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, RTLM6c) DefaultLiveMap should handle empty map entries in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap() // Empty map entries - ), - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - val update = liveMapManager.applyState(objectState, null) - - assertFalse(liveMap.createOperationIsMerged) // RTLM6b - assertEquals(0, liveMap.data.size) // RTLM6c - should be empty map - - // Assert on update field - should show that key1 was removed - val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, RTLM6c) DefaultLiveMap should handle null map in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = null, // Null map - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - val update = liveMapManager.applyState(objectState, null) - - assertFalse(liveMap.createOperationIsMerged) // RTLM6b - assertEquals(0, liveMap.data.size) // RTLM6c - should be empty map when map is null - - // Assert on update field - should show that key1 was removed - val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, RTLM6d) DefaultLiveMap should merge initial data from create operation from state in sync`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "existingValue") - ) - - val createOp = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "createValue"), - timeserial = "serial1" - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "newValue"), - timeserial = "serial2" - ) - ) - ) - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "stateValue"), - timeserial = "serial3" - ) - ) - ), - createOp = createOp, - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - // RTLM6d - Merge initial data from create operation - val update = liveMapManager.applyState(objectState, null) - - assertEquals(2, liveMap.data.size) // Should have both state and create op entries - assertEquals("stateValue", liveMap.data["key1"]?.data?.string) // State value takes precedence - assertEquals("newValue", liveMap.data["key2"]?.data?.string) // Create op value - - // Assert on update field - should show changes from create operation - val expectedUpdate = mapOf( - "key1" to LiveMapUpdate.Change.UPDATED, // key1 was updated from "existingValue" to "stateValue" - "key2" to LiveMapUpdate.Change.UPDATED // key2 was added from create operation - ) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, RTLM6c, OME2d) DefaultLiveMap should handle tombstoned entries with serialTimestamp in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val expectedTimestamp = 1234567890L - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "newValue"), - timeserial = "serial1", - tombstone = true, - serialTimestamp = expectedTimestamp - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "value2"), - timeserial = "serial2" - ) - ) - ), - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - val update = liveMapManager.applyState(objectState, null) - - assertFalse(liveMap.createOperationIsMerged) // RTLM6b - assertEquals(2, liveMap.data.size) // RTLM6c - assertTrue(liveMap.data["key1"]?.isTombstoned == true) // Should be tombstoned - assertEquals(expectedTimestamp, liveMap.data["key1"]?.tombstonedAt) // Should use provided serialTimestamp - assertEquals("value2", liveMap.data["key2"]?.data?.string) // RTLM6c - - // Assert on update field - should show that key1 was removed (tombstoned) - val expectedUpdate = mapOf( - "key1" to LiveMapUpdate.Change.REMOVED, // key1 was tombstoned - "key2" to LiveMapUpdate.Change.UPDATED // key2 was added - ) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, RTLM6c, OME2d) DefaultLiveMap should handle tombstoned entries without serialTimestamp in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "newValue"), - timeserial = "serial1", - tombstone = true, - serialTimestamp = null // No timestamp provided - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "value2"), - timeserial = "serial2" - ) - ) - ), - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - val beforeOperation = System.currentTimeMillis() - val update = liveMapManager.applyState(objectState, null) - val afterOperation = System.currentTimeMillis() - - assertFalse(liveMap.createOperationIsMerged) // RTLM6b - assertEquals(2, liveMap.data.size) // RTLM6c - assertTrue(liveMap.data["key1"]?.isTombstoned == true) // Should be tombstoned - assertNotNull(liveMap.data["key1"]?.tombstonedAt) // Should have timestamp - assertTrue(liveMap.data["key1"]?.tombstonedAt!! >= beforeOperation) // Should be after operation start - assertTrue(liveMap.data["key1"]?.tombstonedAt!! <= afterOperation) // Should be before operation end - assertEquals("value2", liveMap.data["key2"]?.data?.string) // RTLM6c - - // Assert on update field - should show that key1 was removed (tombstoned) - val expectedUpdate = mapOf( - "key1" to LiveMapUpdate.Change.REMOVED, // key1 was tombstoned - "key2" to LiveMapUpdate.Change.UPDATED // key2 was added - ) - assertEquals(expectedUpdate, update.update) - } - - - @Test - fun `(RTLM15, RTLM15d1, RTLM16) LiveMapManager should apply map create operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "value1"), - timeserial = "serial1" - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "value2"), - timeserial = "serial2" - ) - ) - ) - ) - - // RTLM15d1 - Apply map create operation - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(2, liveMap.data.size) // Should have both entries - assertEquals("value1", liveMap.data["key1"]?.data?.string) // Should have value1 - assertEquals("value2", liveMap.data["key2"]?.data?.string) // Should have value2 - assertTrue(liveMap.createOperationIsMerged) // Should be marked as merged - } - - @Test - fun `(RTLM16, RTLM16d, RTLM23, OME2d) LiveMapManager should merge initial data from create operation with tombstoned entries`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "existingValue") - ) - - val expectedTimestamp = 1234567890L - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "createValue"), - timeserial = "serial2", - tombstone = true, - serialTimestamp = expectedTimestamp - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "newValue"), - timeserial = "serial3" - ), - "key3" to ObjectsMapEntry( - data = null, - timeserial = "serial4", - tombstone = true - ) - ) - ) - ) - - // RTLM16d - Merge initial data from create operation - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(3, liveMap.data.size) // Should have all entries - assertTrue(liveMap.data["key1"]?.isTombstoned == true) // RTLM23a2 - Should be tombstoned - assertEquals(expectedTimestamp, liveMap.data["key1"]?.tombstonedAt) // Should use provided serialTimestamp - assertEquals("newValue", liveMap.data["key2"]?.data?.string) // RTLM23a1 - Should be added - assertTrue(liveMap.data["key3"]?.isTombstoned == true) // RTLM23a2 - Should be tombstoned - assertTrue(liveMap.createOperationIsMerged) // RTLM23b - Should be marked as merged - } - - @Test - fun `(RTLM15, RTLM15d2, RTLM7) LiveMapManager should apply map set operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "oldValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM15d2 - Apply map set operation - liveMapManager.applyOperation(operation, "serial2", null) - - assertEquals("newValue", liveMap.data["key1"]?.data?.string) // RTLM7a2a - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // RTLM7a2b - assertFalse(liveMap.data["key1"]?.isTombstoned == true) // RTLM7a2c - } - - @Test - fun `(RTLM15, RTLM15d3, RTLM8) LiveMapManager should apply map remove operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "value1") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "key1") - ) - - val expectedTimestamp = 1234567890L - // RTLM15d3 - Apply map remove operation with provided timestamp - liveMapManager.applyOperation(operation, "serial2", expectedTimestamp) - - assertNull(liveMap.data["key1"]?.data) // RTLM8a2a - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // RTLM8a2b - assertTrue(liveMap.data["key1"]?.isTombstoned == true) // RTLM8a2c - assertEquals(expectedTimestamp, liveMap.data["key1"]?.tombstonedAt) // RTLM8c3 - Should use provided timestamp - } - - @Test - fun `(RTLM8, RTLM8c3, OME2d) LiveMapManager should use current time when no timestamp provided for map remove operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "value1") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "key1") - ) - - val beforeOperation = System.currentTimeMillis() - // RTLM8c3 - Apply map remove operation without timestamp (should use current time) - liveMapManager.applyOperation(operation, "serial2", null) - val afterOperation = System.currentTimeMillis() - - assertNull(liveMap.data["key1"]?.data) // RTLM8a2a - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // RTLM8a2b - assertTrue(liveMap.data["key1"]?.isTombstoned == true) // RTLM8a2c - assertNotNull(liveMap.data["key1"]?.tombstonedAt) // Should have timestamp - assertTrue(liveMap.data["key1"]?.tombstonedAt!! >= beforeOperation) // Should be after operation start - assertTrue(liveMap.data["key1"]?.tombstonedAt!! <= afterOperation) // Should be before operation end - } - - @Test - fun `(RTLM15d1b) LiveMapManager applyOperation returns true for MAP_CREATE`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate(semantics = ObjectsMapSemantics.LWW, entries = emptyMap()) - ) - - // RTLM15d1b - Should return true for successful MAP_CREATE - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertTrue(result, "applyOperation should return true for MAP_CREATE") - } - - @Test - fun `(RTLM15d2b) LiveMapManager applyOperation returns true for MAP_SET`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet(key = "key1", value = ObjectData(string = "value1")) - ) - - // RTLM15d2b - Should return true for successful MAP_SET - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertTrue(result, "applyOperation should return true for MAP_SET") - } - - @Test - fun `(RTLM15d3b) LiveMapManager applyOperation returns true for MAP_REMOVE`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "key1") - ) - - // RTLM15d3b - Should return true for successful MAP_REMOVE - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertTrue(result, "applyOperation should return true for MAP_REMOVE") - } - - @Test - fun `(RTLM15d5b) LiveMapManager applyOperation returns true for OBJECT_DELETE`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.ObjectDelete, - objectId = "map:testMap@1", - ) - - // RTLM15d5b - Should return true for OBJECT_DELETE (tombstone) - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertTrue(result, "applyOperation should return true for OBJECT_DELETE") - assertTrue(liveMap.isTombstoned, "map should be tombstoned after ObjectDelete") - } - - @Test - fun `(RTLM15, RTLM15d4) LiveMapManager should return false for unsupported action`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, // Unsupported action for map - objectId = "map:testMap@1", - counterCreate = io.ably.lib.objects.CounterCreate(count = 20.0) - ) - - // RTLM15d4 - Should return false for unsupported action (no longer throws) - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertFalse(result, "Should return false for unsupported action") - } - - @Test - fun `(RTLM16, RTLM16b) LiveMapManager should skip map create operation if already merged`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set create operation as already merged - liveMap.createOperationIsMerged = true - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "value1"), - timeserial = "serial1" - ) - ) - ) - ) - - // RTLM16b - Should skip if already merged - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(0, liveMap.data.size) // Should not change (still empty) - assertTrue(liveMap.createOperationIsMerged) // Should remain merged - } - - - - @Test - fun `(RTLM16, RTLM16d, RTLM23) LiveMapManager should merge initial data from create operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "createValue"), - timeserial = "serial2" - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "newValue"), - timeserial = "serial3" - ), - "key3" to ObjectsMapEntry( - data = null, - timeserial = "serial4", - tombstone = true - ) - ) - ) - ) - - // RTLM16d - Merge initial data from create operation - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(3, liveMap.data.size) // Should have all entries - assertEquals("createValue", liveMap.data["key1"]?.data?.string) // RTLM23a1 - Should be updated - assertEquals("newValue", liveMap.data["key2"]?.data?.string) // RTLM23a1 - Should be added - assertTrue(liveMap.data["key3"]?.isTombstoned == true) // RTLM23a2 - Should be tombstoned - assertTrue(liveMap.createOperationIsMerged) // RTLM23b - Should be marked as merged - } - - @Test - fun `(RTLM7, RTLM7b) LiveMapManager should create new entry for map set operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "newKey", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM7b - Create new entry - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(1, liveMap.data.size) // Should have one entry - assertEquals("newValue", liveMap.data["newKey"]?.data?.string) // RTLM7b1 - assertEquals("serial1", liveMap.data["newKey"]?.timeserial) // Should have serial - assertFalse(liveMap.data["newKey"]?.isTombstoned == true) // RTLM7b2 - } - - @Test - fun `(RTLM7, RTLM7a) LiveMapManager should skip map set operation with lower serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with higher serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial2", // Higher than "serial1" - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM7a - Should skip operation with lower serial - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals("existingValue", liveMap.data["key1"]?.data?.string) // Should not change - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should keep original serial - } - - @Test - fun `(RTLM8, RTLM8b) LiveMapManager should create tombstoned entry for map remove operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "nonExistingKey") - ) - - // RTLM8b - Create tombstoned entry for non-existing key - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(1, liveMap.data.size) // Should have one entry - assertNull(liveMap.data["nonExistingKey"]?.data) // RTLM8b1 - assertEquals("serial1", liveMap.data["nonExistingKey"]?.timeserial) // Should have serial - assertTrue(liveMap.data["nonExistingKey"]?.isTombstoned == true) // RTLM8b2 - } - - @Test - fun `(RTLM8, RTLM8a) LiveMapManager should skip map remove operation with lower serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with higher serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial2", // Higher than "serial1" - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "key1") - ) - - // RTLM8a - Should skip operation with lower serial - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals("existingValue", liveMap.data["key1"]?.data?.string) // Should not change - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should keep original serial - assertFalse(liveMap.data["key1"]?.isTombstoned == true) // Should not be tombstoned - } - - @Test - fun `(RTLM9, RTLM9b) LiveMapManager should handle null serials correctly`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with null serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = null, - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM9b - Both null serials should be treated as equal - liveMapManager.applyOperation(operation, null, null) - - assertEquals("existingValue", liveMap.data["key1"]?.data?.string) // Should not change - } - - @Test - fun `(RTLM9, RTLM9d) LiveMapManager should apply operation with serial when entry has null serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with null serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = null, - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM9d - Operation serial is greater than missing entry serial - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals("newValue", liveMap.data["key1"]?.data?.string) // Should be updated - assertEquals("serial1", liveMap.data["key1"]?.timeserial) // Should have new serial - } - - @Test - fun `(RTLM9, RTLM9c) LiveMapManager should skip operation with null serial when entry has serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM9c - Missing operation serial is lower than existing entry serial - liveMapManager.applyOperation(operation, null, null) - - assertEquals("existingValue", liveMap.data["key1"]?.data?.string) // Should not change - assertEquals("serial1", liveMap.data["key1"]?.timeserial) // Should keep original serial - } - - @Test - fun `(RTLM9, RTLM9e) LiveMapManager should apply operation with higher serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with lower serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM9e - Higher serial should be applied - liveMapManager.applyOperation(operation, "serial2", null) - - assertEquals("newValue", liveMap.data["key1"]?.data?.string) // Should be updated - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should have new serial - } - - @Test - fun `(RTLM9, RTLM9e) LiveMapManager should skip operation with lower serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with higher serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial2", - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM9e - Lower serial should be skipped - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals("existingValue", liveMap.data["key1"]?.data?.string) // Should not change - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should keep original serial - } - - @Test - fun `(RTLM16, RTLM16c) DefaultLiveMap should throw error for mismatched semantics`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.Unknown, // This should match, but we'll test error case - entries = emptyMap() - ) - ) - - val exception = assertFailsWith { - liveMapManager.applyOperation(operation, "serial1", null) - } - - val errorInfo = exception.errorInfo - kotlin.test.assertNotNull(errorInfo, "Error info should not be null") // RTLM16c - - // Assert on error codes - kotlin.test.assertEquals(92000, exception.errorInfo?.code) // InvalidObject error code - kotlin.test.assertEquals(500, exception.errorInfo?.statusCode) // InternalServerError status code - } - - @Test - fun shouldCalculateMapDifferenceCorrectly() { - // Test case 1: No changes - val prevData1 = mapOf() - val newData1 = mapOf() - val result1 = livemapManager.calculateUpdateFromDataDiff(prevData1, newData1) - assertEquals(emptyMap(), result1.update, "Should return empty map for no changes") - - // Test case 2: Entry added - val prevData2 = mapOf() - val newData2 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ) - ) - val result2 = livemapManager.calculateUpdateFromDataDiff(prevData2, newData2) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result2.update, "Should detect added entry") - - // Test case 3: Entry removed - val prevData3 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ) - ) - val newData3 = mapOf() - val result3 = livemapManager.calculateUpdateFromDataDiff(prevData3, newData3) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.REMOVED), result3.update, "Should detect removed entry") - - // Test case 4: Entry updated - val prevData4 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ) - ) - val newData4 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "2", - data = ObjectData(string = "value2") - ) - ) - val result4 = livemapManager.calculateUpdateFromDataDiff(prevData4, newData4) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result4.update, "Should detect updated entry") - - // Test case 5: Entry tombstoned - val prevData5 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ) - ) - val newData5 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = true, - timeserial = "2", - data = null - ) - ) - val result5 = livemapManager.calculateUpdateFromDataDiff(prevData5, newData5) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.REMOVED), result5.update, "Should detect tombstoned entry") - - // Test case 6: Entry untombstoned - val prevData6 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = true, - timeserial = "1", - data = null - ) - ) - val newData6 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "2", - data = ObjectData(string = "value1") - ) - ) - val result6 = livemapManager.calculateUpdateFromDataDiff(prevData6, newData6) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result6.update, "Should detect untombstoned entry") - - // Test case 7: Both entries tombstoned (noop) - val prevData7 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = true, - timeserial = "1", - data = null - ) - ) - val newData7 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = true, - timeserial = "2", - data = ObjectData(string = "value1") - ) - ) - val result7 = livemapManager.calculateUpdateFromDataDiff(prevData7, newData7) - assertEquals(emptyMap(), result7.update, "Should not detect change for both tombstoned entries") - - // Test case 8: New tombstoned entry (noop) - val prevData8 = mapOf() - val newData8 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = true, - timeserial = "1", - data = null - ) - ) - val result8 = livemapManager.calculateUpdateFromDataDiff(prevData8, newData8) - assertEquals(emptyMap(), result8.update, "Should not detect change for new tombstoned entry") - - // Test case 9: Multiple changes - val prevData9 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ), - "key2" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value2") - ) - ) - val newData9 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "2", - data = ObjectData(string = "value1_updated") - ), - "key3" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value3") - ) - ) - val result9 = livemapManager.calculateUpdateFromDataDiff(prevData9, newData9) - val expected9 = mapOf( - "key1" to LiveMapUpdate.Change.UPDATED, - "key2" to LiveMapUpdate.Change.REMOVED, - "key3" to LiveMapUpdate.Change.UPDATED - ) - assertEquals(expected9, result9.update, "Should detect multiple changes correctly") - - // Test case 10: ObjectId references - val prevData10 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(objectId = "obj1") - ) - ) - val newData10 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(objectId = "obj2") - ) - ) - val result10 = livemapManager.calculateUpdateFromDataDiff(prevData10, newData10) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result10.update, "Should detect objectId change") - - // Test case 11: Same data, no change - val prevData11 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ) - ) - val newData11 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "2", - data = ObjectData(string = "value1") - ) - ) - val result11 = livemapManager.calculateUpdateFromDataDiff(prevData11, newData11) - assertEquals(emptyMap(), result11.update, "Should not detect change for same data") - } - - @Test - fun `(RTLM6, OM2j) DefaultLiveMap should handle tombstone with serialTimestamp in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val expectedTimestamp = 1234567890L - val objectState = ObjectState( - objectId = "map:testMap@1", - map = null, // Null map for tombstone - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = true, // Object is tombstoned - ) - - val update = liveMapManager.applyState(objectState, expectedTimestamp) - - assertTrue(liveMap.isTombstoned) // Should be tombstoned - assertEquals(expectedTimestamp, liveMap.TombstonedAt) // Should use provided timestamp - assertEquals(0, liveMap.data.size) // Should be empty after tombstone - - // Assert on update field - should show that key1 was removed - val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, OM2j) DefaultLiveMap should handle tombstone without serialTimestamp in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = null, // Null map for tombstone - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = true, // Object is tombstoned - ) - - val beforeOperation = System.currentTimeMillis() - val update = liveMapManager.applyState(objectState, null) - val afterOperation = System.currentTimeMillis() - - assertTrue(liveMap.isTombstoned) // Should be tombstoned - assertNotNull(liveMap.TombstonedAt) // Should have timestamp - assertTrue(liveMap.TombstonedAt!! >= beforeOperation) // Should be after operation start - assertTrue(liveMap.TombstonedAt!! <= afterOperation) // Should be before operation end - assertEquals(0, liveMap.data.size) // Should be empty after tombstone - - // Assert on update field - should show that key1 was removed - val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM24) applyMapClear removes entries older than clear serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "value1") - ) - liveMap.data["key2"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial3", - data = ObjectData(string = "value2") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapClear, - objectId = "map:testMap@1", - mapClear = MapClear - ) - - // Apply MAP_CLEAR with serial "serial2" — between serial1 and serial3 - liveMapManager.applyOperation(operation, "serial2", null) - - assertNull(liveMap.data["key1"], "Entry at serial1 should be removed") - assertNotNull(liveMap.data["key2"], "Entry at serial3 should be kept") - assertEquals("serial2", liveMap.clearTimeserial) - } - - @Test - fun `(RTLM24c) applyMapClear skips when existing clearTimeserial is newer`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "value1") - ) - liveMap.clearTimeserial = "serial3" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapClear, - objectId = "map:testMap@1", - mapClear = MapClear - ) - - liveMapManager.applyOperation(operation, "serial2", null) - - // clearTimeserial should remain unchanged and data should be untouched - assertEquals("serial3", liveMap.clearTimeserial) - assertNotNull(liveMap.data["key1"], "Entry should not be removed") - } - - @Test - fun `(RTLM25) clearTimeserial is set after MAP_CLEAR`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - assertNull(liveMap.clearTimeserial) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapClear, - objectId = "map:testMap@1", - mapClear = MapClear - ) - - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals("serial1", liveMap.clearTimeserial) - } - - @Test - fun `(RTLM7h) applyMapSet skips when op serial is less than or equal to clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.clearTimeserial = "serial2" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet(key = "key1", value = ObjectData(string = "value1")) - ) - - liveMapManager.applyOperation(operation, "serial1", null) - - assertNull(liveMap.data["key1"], "Entry should NOT be added when op serial <= clearTimeserial") - } - - @Test - fun `(RTLM7h) applyMapSet applies when op serial is greater than clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.clearTimeserial = "serial1" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet(key = "key1", value = ObjectData(string = "value1")) - ) - - liveMapManager.applyOperation(operation, "serial2", null) - - assertNotNull(liveMap.data["key1"], "Entry should be added when op serial > clearTimeserial") - assertEquals("value1", liveMap.data["key1"]?.data?.string) - } - - @Test - fun `(RTLM8g) applyMapRemove skips when op serial is less than or equal to clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial3", - data = ObjectData(string = "value1") - ) - liveMap.clearTimeserial = "serial2" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "key1") - ) - - liveMapManager.applyOperation(operation, "serial1", null) - - assertFalse(liveMap.data["key1"]?.isTombstoned == true, "Entry should NOT be tombstoned when op serial <= clearTimeserial") - } - - @Test - fun `(RTLM6i) applyState sets clearTimeserial from objectState`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap(), - clearTimeserial = "serial1" - ), - siteTimeserials = emptyMap(), - tombstone = false, - ) - - liveMapManager.applyState(objectState, null) - - assertEquals("serial1", liveMap.clearTimeserial) - } - - @Test - fun `(RTLM6i) applyState resets clearTimeserial to null when objectState has no clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.clearTimeserial = "serial1" - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap(), - clearTimeserial = null - ), - siteTimeserials = emptyMap(), - tombstone = false, - ) - - liveMapManager.applyState(objectState, null) - - assertNull(liveMap.clearTimeserial) - } - - @Test - fun `(RTLM6i, RTLM6d, RTLM7h) applyState filters createOp entries older than or equal to clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // createOp has three entries: - // key-null-serial — no timeserial (treated as pre-clear by RTLM7h) - // key-old-serial — serial1, strictly older than the clear serial (serial2) - // key-new-serial — serial3, strictly newer than the clear serial (serial2) - val createOp = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key-null-serial" to ObjectsMapEntry( - data = ObjectData(string = "nullSerialValue"), - timeserial = null - ), - "key-old-serial" to ObjectsMapEntry( - data = ObjectData(string = "oldSerialValue"), - timeserial = "serial1" - ), - "key-new-serial" to ObjectsMapEntry( - data = ObjectData(string = "newSerialValue"), - timeserial = "serial3" - ) - ) - ) - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap(), - clearTimeserial = "serial2" // RTLM6i: set before createOp entries are merged - ), - createOp = createOp, - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - liveMapManager.applyState(objectState, null) - - // RTLM7h: entries with null or older-than-clear serials must be filtered out - assertNull(liveMap.data["key-null-serial"], "Entry with null serial should be filtered by RTLM7h") - assertNull(liveMap.data["key-old-serial"], "Entry with serial1 <= clearTimeserial serial2 should be filtered by RTLM7h") - // Entry whose serial is strictly newer than clearTimeserial must survive - assertNotNull(liveMap.data["key-new-serial"], "Entry with serial3 > clearTimeserial serial2 should be present") - assertEquals("newSerialValue", liveMap.data["key-new-serial"]?.data?.string) - } - - @Test - fun `(RTLM4) clearData resets clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - - liveMap.clearTimeserial = "serial1" - liveMap.clearData() - - assertNull(liveMap.clearTimeserial) - } - - @Test - fun `(RTLM15d8) applyOperation returns true for MAP_CLEAR`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapClear, - objectId = "map:testMap@1", - mapClear = MapClear - ) - - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertTrue(result, "applyOperation should return true for MAP_CLEAR") - } -}