Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .claude/skills/uts-to-kotlin/references/objects-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ doubt, that IDL is the source of truth; this doc is the applied version of it fo
15. [Worked example](#15-worked-example)
16. [Quick symbol index](#16-symbol-index)
17. [Internal-graph symbol map (unit specs → `:liveobjects`'s own tests)](#17-internal-mappings)
18. [REST entry point: `RestObject` → `HttpObject`](#18-http-object)

---

Expand Down Expand Up @@ -896,3 +897,60 @@ tiers, and none of the 5 internal unit specs use it.)

Keep the `uts` discipline in the migrated tests: one `@Test` per spec case, `/** @UTS objects/unit/… */`
KDoc tags, and a module-local `deviations.md` recording every §17.9 deviation used.

---

## 18. REST entry point: `RestObject` → `HttpObject` <a id="18-http-object"></a>

The `rest_object_*.md` unit specs and `rest_object_test.md` integration spec target the stateless REST
API (RSL16, RSO*). ably-java follows ably-js's Rest → Http rename: spec `RestObject*` / `RestLive*` types
are `HttpObject*` / `HttpLive*`. The entry point is in `io.ably.pubsub.liveobjects`; every other type is
in `io.ably.pubsub.liveobjects.http`.

**These specs live in `:core`, not `:liveobjects`.** `HttpObject` is part of the core HTTP client, so
override the resolver's objects `targetDir` for the `rest_object_*` specs:

| Tier | Directory | Package |
|---|---|---|
| unit | `lib/src/test/kotlin/io/ably/pubsub/uts/unit/objects` | `io.ably.pubsub.uts.unit.objects` |
| integration | `lib/src/test/kotlin/io/ably/pubsub/uts/integration/standard/objects` | `io.ably.pubsub.uts.integration.standard.objects` |

Run them with `./gradlew :core:runUtsUnitTests` / `:core:runUtsIntegrationTests`, and record their
deviations in `lib/src/test/kotlin/io/ably/pubsub/uts/deviations.md`. The one exception is
`objects/integration/RSL16/realtime-write-rest-read-0`: it writes through the realtime LiveObjects
plugin, so it stays in `:liveobjects` as `uts/integration/RestObjectRealtimeInteropTest.kt`.

Unit-tier helpers are in `lib/.../uts/unit/objects/HttpObjectHelpers.kt` (`setupRestObject`,
`respondWithBody`, `parseJson`, `msgpackEncode`/`msgpackDecode`, `encodeUriComponent`, and
`PendingRequest.path` / `.queryParams` / `.header`).

| Spec | ably-java (Kotlin) |
|---|---|
| `Rest(options: …, plugins: { Objects })` | `TestHttpClient { … }` — `HttpObject` is built into the HTTP client (`lib`), no plugin |
| `channel.object` (REST) | `` channel.`object` `` — field of type `HttpObject`, always available (RSL16b not applicable) |
| `AWAIT channel.object.get(RestObjectGetParams(objectId:, path:))` (compact unset or `true`) | `` channel.`object`.getCompact(HttpObjectGetParams.of(objectId, path)) `` (or `ofObjectId(…)` / `ofPath(…)` when only one is set) → `JsonElement?` (blocking); `compact` is never sent |
| `AWAIT channel.object.get(RestObjectGetParams(…, compact: false))` | `` channel.`object`.get(HttpObjectGetParams.of(…)) `` → `HttpObjectGetFullResult?` (blocking); sends `compact=false` |
| compact result | Gson `JsonElement` (`JsonObject` for a map); binary values are Base64 strings for both protocols. Compare with spec literals via `fromJson(…)` from `HttpObjectHelpers.kt` (deviation RSO2a1c) |
| narrowing a full result (`x.map…`, `x.counter…`, leaf fields) | `x.type` (`ValueType`) plus fail-fast casts like realtime `Instance`: `asLiveMap()`, `asLiveCounter()`, `asLiveObject()`, `asObjectData()`, and leaf values `asString()`, `asNumber()`, `asBoolean()`, `asBinary()`, `asJsonObject()`, `asJsonArray()`; prefer them over `assertIs` chains |
| full result `RestLiveMap` / `RestLiveCounter` | `HttpLiveMap` (`objectId`, `semantics`, `entries`) / `HttpLiveCounter` (`objectId`, `count`) |
| `x.map.semantics` / `x.map.entries[k].data` | `x.semantics` / `x.entries.getValue(k).data` |
| `x.counter.data.number` | `x.count` (`Double`) |
| full leaf `ObjectData` | `HttpObjectData` (extends `ObjectData`): `string`, `number` (`Double`), `boolean`, `bytes`, `json` (`JsonElement`); no `encoding` accessor |
| unrecognised object `{ objectId, futureType }` | plain `HttpLiveObject`: `objectId` + `unrecognizedFields["futureType"]` (`JsonObject`; compare via `fromJson`) |
| `RestObjectOperation(objectId: o, mapSet: RestObjectMapSet(key: k, value: v))` | `HttpObjectOperation.mapSet(k, v).withObjectId(o)` (also `withPath`, `withId`) |
| `mapCreate: RestObjectMapCreate(semantics: "LWW", entries: { k: RestObjectMapEntry(data: v) })` | `HttpObjectOperation.mapCreate(ObjectsMapSemantics.LWW, mapOf(k to v))` — entries map straight to `PublishObjectData` |
| `mapRemove` / `counterCreate` / `counterInc` | `HttpObjectOperation.mapRemove(k)` / `.counterCreate(n)` / `.counterInc(n)` |
| `*CreateWithObjectId(initialValue:, nonce:)` + `objectId:` | `HttpObjectOperation.mapCreateWithObjectId(objectId, initialValue, nonce)` (or pass the `HttpObjectGenerateIdResult`) |
| `PublishObjectData(string:/number:/boolean:/bytes:/json:/objectId:)` | `PublishObjectData.string/number/bool/bytes/json/objectId(…)` |
| `semantics: "not-a-semantics"` | `ObjectsMapSemantics.UNKNOWN` |
| `AWAIT channel.object.publish(op \| [ops])` | `` channel.`object`.publish(op) `` / `.publish(listOf(…))` → `HttpObjectPublishResult` (blocking) |
| `AWAIT channel.object.generateObjectId(RestObjectGenerateIdBody(counterCreate: RestObjectCounterCreate(count: n)))` | `` channel.`object`.generateObjectId(HttpObjectGenerateIdBody.counterCreate(n)) `` (blocking) |

**Async: not §3.** Unlike `RealtimeObject`, `HttpObject` follows the HTTP channel convention, not
`CompletableFuture`: each method is blocking (returns the result, throws `AblyException`) and has an
`…Async(…, Callback<T>)` twin. Translate spec `AWAIT` to the blocking call (no `.await()`), and
`AWAIT x FAILS WITH error` to `assertFailsWith<AblyException> { x }`.

Operations and generate-ID bodies with zero or several bodies are unrepresentable (T-4, in
`deviations.md`). JSON leaves go on the wire in the OD2g `json` field (OD4c5/OD4d5), as the spec says.
Comment thread
ttypic marked this conversation as resolved.
Resolved

48 changes: 34 additions & 14 deletions lib/src/main/java/io/ably/pubsub/http/Auth.java
Original file line number Diff line number Diff line change
Expand Up @@ -876,21 +876,12 @@ else if(!request.keyName.equals(keyName))
/* timestamp */
if(request.timestamp == 0) {
if(options.queryTime) {
long oldNanoTimeDelta = nanoTimeDelta;
long currentNanoTimeDelta = clock.currentTimeMillis() - clock.nanoTime()/(1000*1000);

if (timeDelta != Long.MAX_VALUE) {
/* system time changed by more than 500ms since last time? */
if(Math.abs(oldNanoTimeDelta - currentNanoTimeDelta) > 500)
timeDelta = Long.MAX_VALUE;
}

if (timeDelta != Long.MAX_VALUE) {
request.timestamp = timestamp() + timeDelta;
nanoTimeDelta = currentNanoTimeDelta;
Long known = serverTimestampIfKnown();
if (known != null) {
request.timestamp = known;
} else {
request.timestamp = ably.time();
timeDelta = request.timestamp - timestamp();
setServerTime(request.timestamp);
}
}
else {
Expand Down Expand Up @@ -1256,6 +1247,35 @@ public long serverTimestamp() {
return delta != Long.MAX_VALUE ? clientTime + timeDelta : clientTime;
}

/**
* [Internal] The current server time derived from the stored offset, or {@code null} if the
* offset is unknown or the device clock has jumped by more than 500 ms since it was last used
* (in which case the offset is discarded). Shared by {@code queryTime} token requests and
* {@code generateObjectId}, so a client fetches {@code /time} once for both.
*/
public synchronized Long serverTimestampIfKnown() {
if (timeDelta == Long.MAX_VALUE) return null;
long now = timestamp();
long currentNanoTimeDelta = now - clock.nanoTime()/(1000*1000);
/* system time changed by more than 500ms since last time? */
if (Math.abs(nanoTimeDelta - currentNanoTimeDelta) > 500) {
timeDelta = Long.MAX_VALUE;
return null;
}
nanoTimeDelta = currentNanoTimeDelta;
return now + timeDelta;
}

/**
* [Internal] Stores the offset to the local clock from a server time just fetched from
* {@code /time}.
*/
public synchronized void setServerTime(long serverTime) {
long now = timestamp();
timeDelta = serverTime - now;
nanoTimeDelta = now - clock.nanoTime()/(1000*1000);
}

private static final String TAG = Auth.class.getName();
private final PubSubBase ably;
private final Clock clock;
Expand All @@ -1270,7 +1290,7 @@ public long serverTimestamp() {
/**
* Time delta is server time minus client time, in milliseconds, MAX_VALUE if not obtained yet
*/
private long timeDelta = Long.MAX_VALUE;
private volatile long timeDelta = Long.MAX_VALUE;
/**
* Time delta between System.nanoTime() and System.currentTimeMillis. If it changes significantly it
* suggests device time/date has changed
Expand Down
11 changes: 11 additions & 0 deletions lib/src/main/java/io/ably/pubsub/http/ChannelBase.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.ably.pubsub.http;

import io.ably.pubsub.http.BasePaginatedQuery;
import io.ably.pubsub.liveobjects.HttpObject;
import io.ably.pubsub.liveobjects.http.DefaultHttpObject;
import io.ably.pubsub.http.HttpRequester;
import io.ably.pubsub.http.HttpScheduler;
import io.ably.pubsub.http.HttpCore;
Expand Down Expand Up @@ -48,6 +50,14 @@ public class ChannelBase {
*/
public final HttpAnnotations annotations;

/**
* The LiveObjects entry point for this channel. It is built into the HTTP client and needs no
* plugin.
* <p>
* Spec: RSL16
*/
public final HttpObject object;


private final MessageEditsMixin messageEditsMixin;

Expand Down Expand Up @@ -607,6 +617,7 @@ public void getMessageVersionsAsync(String serial, Param[] params, Callback<Asyn
this.presence = new Presence();
this.annotations = new HttpAnnotations(name, ably.http, ably.options, options);
this.messageEditsMixin = new MessageEditsMixin(basePath, ably.options, options, ably.auth);
this.object = new DefaultHttpObject(ably, basePath, ably.objectsServerTime);
}

private final PubSubBase ably;
Expand Down
6 changes: 6 additions & 0 deletions lib/src/main/java/io/ably/pubsub/http/PubSubBase.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.ably.pubsub.http;

import io.ably.pubsub.liveobjects.http.HttpObjectServerTime;
import io.ably.pubsub.annotation.Experimental;
import io.ably.pubsub.platform.Platform;
import io.ably.pubsub.push.Push;
Expand Down Expand Up @@ -70,6 +71,11 @@ public abstract class PubSubBase implements AutoCloseable {
public final Push push;
protected final PlatformAgentProvider platformAgentProvider;

/**
* Server time shared by the {@code object} entry points of this client's HTTP channels (RTO16).
*/
final HttpObjectServerTime objectsServerTime = new HttpObjectServerTime(this);

/**
* Constructs a client object using an Ably API key or token string.
* <p>
Expand Down
Loading
Loading