Skip to content
Draft
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
5 changes: 5 additions & 0 deletions specifications/objects-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ Objects feature enables clients to store shared data as "objects" on a channel.
- `(RTO5c5)` The `bufferedObjectOperations` list must be cleared
- `(RTO5c9)` The `appliedOnAckSerials` set ([RTO7b](#RTO7b)) must be cleared. A state sync causes the channel's LiveObjects data to be replaced, so after a state sync the `appliedOnAckSerials` no longer accurately describes which operations have been applied to the channel's LiveObjects data
- `(RTO5c8)` The [RTO17](#RTO17) sync state must transition to `SYNCED`
- `(RTO27)` When the channel transitions to a state other than `ATTACHED`, the client library must manage the stored objects data as follows (for the effect of these transitions on an in-progress `publishAndApply`, see [RTO20e1](#RTO20e1)):
- `(RTO27a)` When the channel transitions to the `DETACHED` or `FAILED` state, the current state of the objects data can no longer be known, so the client library must:
- `(RTO27a1)` For every object in the internal `ObjectsPool`, clear its internal data, resetting it to the zero value for its type (an empty map, or a counter with value `0`), without emitting any `LiveObjectUpdate` events. The objects themselves remain in the `ObjectsPool`; only their data is cleared
- `(RTO27a2)` The `SyncObjectsPool` must be cleared
- `(RTO27b)` When the channel transitions to the `SUSPENDED` state, the client library must retain the stored objects data unchanged, since the connection may still recover and the retained data remains a valid best-effort local copy
- `(RTO6)` Certain object operations may require creating a new object if one does not already exist in the internal `ObjectsPool` for the given `objectId`. This can be done as follows:
- `(RTO6a)` If an object with `objectId` exists in `ObjectsPool`, do not create a new object
- `(RTO6b)` The expected type of the object can be inferred from the provided `objectId`:
Expand Down
8 changes: 8 additions & 0 deletions uts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ ASSERT error.code == 40160
AWAIT_STATE client.connection.state == ConnectionState.connected
```

**Identifier naming.** Identifiers that name SDK/spec surface mirror the specification's own names and
casing — `PascalCase` types (`ObjectsPool`), `camelCase` fields and methods (`bufferedObjectOperations`,
`applyOperation`) — so they grep straight back to the spec. Behaviours the spec describes but does not
name get a `camelCase` coinage in the same style (`processAttached`, `processChannelState`), never
`snake_case`. `snake_case` is reserved for test-harness constructs (`setup_synced_channel`, `mock_ws`,
`send_to_client`, `build_*`, `poll_until`). Spec *file* names are `snake_case` (`objects_pool.md`) —
unrelated to symbol casing. Full rules: [docs/writing-test-specs.md](docs/writing-test-specs.md#identifier-naming).

Pseudocode maps to language idioms rather than prescribing exact syntax:

- **Absent values**: `== null` means the language-appropriate "absent" value — `undefined` in
Expand Down
67 changes: 67 additions & 0 deletions uts/docs/writing-test-specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,73 @@ Tests that all REST requests include the `Ably-Agent` header with correct format

## Pseudocode Conventions

### Identifier Naming

Every identifier in pseudocode is either **spec surface** (an SDK/spec name, cases 1–2 below) or
**test harness** (scaffolding, case 3), and its **casing is the visual signal** of which. Getting this
right keeps the spec surface greppable straight back to the specification and keeps test scaffolding
visually distinct from it.

**1. Spec surface — mirror the specification's own names exactly.** Anything that names an SDK type,
field, property, or method that a feature specification (`features.md`, `objects-features.md`, …)
already defines must use the **identical name and casing** the spec uses:

- **Types / concepts → `PascalCase`**: `ObjectsPool`, `SyncObjectsPool`, `InternalLiveMap`,
`InternalLiveCounter`, `Connection`.
- **Fields / properties → `camelCase`**: `bufferedObjectOperations`, `appliedOnAckSerials`,
`clearTimeserial`, `siteTimeserials`, `createOperationIsMerged`.
- **Methods → the spec's member, written as a call**: the spec's `Type#method` form (e.g.
`InternalLiveMap#set`, `RealtimeObject#on`) is written `object.method(...)`; bare method names
(`applyOperation`, `replaceData`) match the spec verbatim.

Do not rename, abbreviate, or re-case a spec identifier — a reader must be able to grep it out of the
specification unchanged.

**2. Behaviour the specification describes but does not name.** Some clauses describe an action without
giving it a method name (e.g. RTO4: *"when a channel `ATTACHED` `ProtocolMessage` is received, the
client library must …"* — no method is named). When a white-box test needs to invoke that behaviour
directly, coin a name **in the specification's own style — `camelCase`, never `snake_case`** — and use
it consistently across the suite. Established coinages: `processAttached`, `processObjectSync`,
`processObjectMessage`, `syncState`, `processChannelState`. These are UTS conventions rather than spec
names, but they read as though the spec could have named them.

**3. Test-harness constructs → `snake_case`.** Everything that is scaffolding rather than SDK/spec
surface — fixtures, mocks, builders, polling/flush helpers — is `snake_case`, so it is instantly
distinguishable from the spec surface: `setup_synced_channel`, `mock_ws`, `send_to_client`,
`build_object_message`, `build_counter_inc`, `install_mock`, `respond_with_success`, `poll_until`,
`process_pending_events`, `encode_uri_component`.

**Internal (white-box) access** uses the spec's internal name, `camelCase`d: the internal `ObjectsPool`
(RTO3) is reached as `object.objectsPool`; internal fields keep their spec names
(`bufferedObjectOperations`, `appliedOnAckSerials`). How each SDK exposes these is covered in the
derived-test mapping docs.

**Wire and enum values follow the protocol/spec — not these two buckets.** Identifiers that name
wire-format data or enum members come from `protocol.md` / `features.md` and are matched verbatim; they
are **not** harness, even when `snake_case`:

- **Wire fields & query parameters** — mostly `camelCase` (`channelSerial`, `connectionId`,
`msgSerial`), with a few `snake_case` (the `request_id` query parameter, RSC7c).
- **Enum members** — the spec's enum casing, typically `SCREAMING_SNAKE_CASE`
(the `ChannelMode.PRESENCE_SUBSCRIBE` enum member / `TR3` flag, also written `presence_subscribe` in
some prose; `SYNCED`; `DETACHED`). Render these per the *Enum values* note in the UTS README.

**File names are not identifiers.** Spec *files* are `snake_case` `.md` (`objects_pool.md`,
`realtime_object.md`); this is unrelated to symbol casing. The `ObjectsPool` type stays `PascalCase`
even though it is documented in `objects_pool.md`.

```pseudo
# GOOD — spec surface mirrors the spec; harness is snake_case
{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") # harness → snake_case
pool = channel.object.objectsPool # ObjectsPool (RTO3) → camelCase accessor
update = counter.applyOperation(msg, source: CHANNEL) # spec method, verbatim
ASSERT counter.bufferedObjectOperations.length == 0 # spec field, verbatim

# BAD — spec surface in snake_case: looks like harness, no longer greps back to the spec
pool = channel.object.objects_pool
update = counter.apply_operation(msg)
```

### URI Path Component Encoding

Use `encode_uri_component()` for any variable path segment or query parameter in URL assertions. This is defined in the UTS README. Always use exact equality (`==`) for path assertions, not `CONTAINS`.
Expand Down
9 changes: 9 additions & 0 deletions uts/objects/unit/internal_live_counter.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ Tests the `InternalLiveCounter` CRDT data structure. InternalLiveCounter holds a

Tests operate directly on InternalLiveCounter by calling `applyOperation()` and `replaceData()` with constructed messages. No channel or connection infrastructure is needed.

> **SDK portability note — op-path update return value.** Several tests below assert on the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should try to design the UTS test assertions so that they are as portable as possible. Can this be done here, or are you saying that the tests in the SDKs are necessarily different?

> `LiveCounterUpdate` **returned by `applyOperation(msg, source)`** (`noop`, `update.amount`,
> `tombstone`, `objectMessage`). This models SDKs whose operation application returns the update
> synchronously. An SDK whose operation path returns only a success/failure boolean and instead
> delivers the update via the object's subscription/emit path (e.g. a statically-typed SDK — see
> `objects-features.md` RTTS) may satisfy the same requirement by asserting the **emitted update
> event** and/or the **resulting object state** — these are equivalent observables of the same
> `LiveCounterUpdate`. The sync path (`replaceData()`) returns the update in all SDKs.

## Shared Helpers

See `helpers/standard_test_pool.md` for `build_counter_inc`, `build_counter_create`, `build_object_delete`, `build_object_state`.
Expand Down
3 changes: 3 additions & 0 deletions uts/objects/unit/internal_live_counter_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,9 @@ The `null` row applies only where `null` is passable and distinguishable from an
argument (e.g. a boxed `Integer` in Java). In SDKs whose signature makes null equivalent to
"omitted" (e.g. `increment(amount?: number)` in JavaScript, where a nullish amount takes the
default of 1), the row is not applicable — see the pseudocode conventions in `uts/README.md`.
Such SDKs should instead assert the equivalence directly — `increment(null)` succeeds and
increments by the default of 1 — pinning the null-means-omitted contract so a later signature
or coalescing change surfaces as a conscious decision.

### Setup
```pseudo
Expand Down
10 changes: 10 additions & 0 deletions uts/objects/unit/internal_live_map.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ Tests the `InternalLiveMap` LWW-map CRDT data structure. InternalLiveMap holds a

Tests operate directly on InternalLiveMap by calling `applyOperation()` and `replaceData()` with constructed messages.

> **SDK portability note — op-path update return value.** Several tests below assert on the
> `LiveMapUpdate` **returned by `applyOperation(msg, source)`** (`noop`, the per-key `update` diff
> such as `{ "name": "updated"/"removed" }`, `tombstone`, `objectMessage`). This models SDKs whose
> operation application returns the update synchronously. An SDK whose operation path returns only a
> success/failure boolean and instead delivers the update via the object's subscription/emit path
> (e.g. a statically-typed SDK — see `objects-features.md` RTTS) may satisfy the same requirement by
> asserting the **emitted update event** and/or the **resulting map state** — these are equivalent
> observables of the same `LiveMapUpdate`. The sync path (`replaceData()`) returns the update in all
> SDKs.

## Shared Helpers

See `helpers/standard_test_pool.md` for builder functions.
Expand Down
12 changes: 12 additions & 0 deletions uts/objects/unit/path_object.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,12 @@ ASSERT result["profile"]["prefs"]["theme"] == "dark"
mock_ws.send_to_client(build_object_message("test", [
build_map_set("map:prefs@1000", "back_ref", { objectId: "map:profile@1000" }, "99", "remote")
]))

# Quiescence barrier: this is an INBOUND server OBJECT message (send_to_client), not a locally
# published operation — so there is no publish-completion / apply-on-ack point to await here. SDKs
# may apply inbound OBJECT messages asynchronously, so wait until the MAP_SET has been applied before
# this POSITIVE read. (The spec's read-after-send conventions otherwise cover only negative assertions.)
poll_until("back_ref" IN root.get("profile").get("prefs").keys(), timeout: 5s)
```

### Test Steps
Expand Down Expand Up @@ -626,6 +632,12 @@ ASSERT root.get("score").compact() == 100
mock_ws.send_to_client(build_object_message("test", [
build_map_set("map:prefs@1000", "back_ref", { objectId: "map:profile@1000" }, "99", "remote")
]))

# Quiescence barrier: this is an INBOUND server OBJECT message (send_to_client), not a locally
# published operation — so there is no publish-completion / apply-on-ack point to await here. SDKs
# may apply inbound OBJECT messages asynchronously, so wait until the MAP_SET has been applied before
# this POSITIVE read. (The spec's read-after-send conventions otherwise cover only negative assertions.)
poll_until("back_ref" IN root.get("profile").get("prefs").keys(), timeout: 5s)
```

### Test Steps
Expand Down
78 changes: 66 additions & 12 deletions uts/objects/unit/realtime_object.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# RealtimeObject Tests

Spec points: `RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO26`
Spec points: `RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO27`

## Test Type
Unit test with mocked WebSocket client
Expand Down Expand Up @@ -551,6 +551,13 @@ ASSERT events CONTAINS_IN_ORDER ["SYNCING", "SYNCED"]

**Spec requirement:** If same listener registered twice, it is invoked twice per event.

> **OPTIONAL for SDKs that de-duplicate listeners by instance.** RTO18d restates the general
> `EventEmitter#on` contract (RTE4): registering the same listener reference twice adds it twice, so it
> fires twice. Some SDKs deliberately de-duplicate listeners keyed by instance (e.g. ably-java's
> `EventEmitter` documents this as an SDK-wide deviation from RTE4). Such an SDK may instead assert
> `call_count == 1` here — or skip this test — and pin its own behaviour; the assertion below is the
> RTE4 reference behaviour.

### Setup
```pseudo
{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test")
Expand Down Expand Up @@ -1538,17 +1545,9 @@ scenarios = [
},
expected_events: ["SYNCING", "SYNCED"]
},
{
name: "re-attach after detach",
trigger: () => {
mock_ws.send_to_client(ProtocolMessage(action: DETACHED, channel: "test"))
mock_ws.send_to_client(ProtocolMessage(
action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS
))
mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS))
},
expected_events: ["SYNCING", "SYNCED"]
},
// No "re-attach after detach" scenario, deliberately: it is redundant with "re-sync on new
// ATTACHED" below (same onAttached -> new-sync path, RTO4c), and not portably expressible — an
// unsolicited server DETACHED transitions to SUSPENDED (not DETACHED) in some SDKs.
{
name: "re-sync on new ATTACHED",
trigger: () => {
Expand Down Expand Up @@ -1589,3 +1588,58 @@ FOR scenario IN scenarios:

ASSERT events == scenario.expected_events
```

---

## RTO27 - Objects data lifecycle on channel state transitions

**Test ID**: `objects/unit/RTO27/channel-state-data-lifecycle-0`

**Spec requirement:** On a channel transition to `DETACHED`/`FAILED`, every object's data is cleared
without emitting update events (RTO27a); on `SUSPENDED`, the data is retained (RTO27b).

**White-box test.** Like the `internal_live_counter` / `internal_live_map` tests (which call
`applyOperation` directly), this drives the RealtimeObject's internal channel-state handler directly
and inspects the internal `ObjectsPool`. A direct call is required because the behaviour is not
reachable black-box: access after `DETACHED`/`FAILED` throws (RTO25b), and `SUSPENDED` is a
connection-level state that a channel-level mock cannot drive. The abstract symbols map as follows:
- `channel.object.processChannelState(state)` → the SDK's channel-state handler (ably-js
`RealtimeObject.actOnChannelState(state)`, ably-java `DefaultRealtimeObject.handleStateChange(state, false)`).
- `channel.object.objectsPool` → the internal `ObjectsPool` (RTO3) (ably-js `_objectsPool`, ably-java `objectsPool`).

### RTO27a - DETACHED / FAILED clears data without emitting update events

```pseudo
FOR state IN [DETACHED, FAILED]:
{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test")
pool = channel.object.objectsPool # internal ObjectsPool (RTO3)

# sanity: STANDARD_POOL_OBJECTS has been materialised
ASSERT "name" IN pool["root"].data
ASSERT pool["counter:score@1000"].value == 100

# RTO27a1: no update events must be emitted during the clear
updates = []
pool["root"].subscribe((update) => updates.append(update))

channel.object.processChannelState(state) # internal channel-state handler (RTO27)

# RTO27a1: every object's data is cleared; the objects themselves remain in the pool
ASSERT pool["root"].data == {}
ASSERT "counter:score@1000" IN pool
ASSERT pool["counter:score@1000"].value == 0
ASSERT updates.length == 0
```

### RTO27b - SUSPENDED retains data

```pseudo
{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test")
pool = channel.object.objectsPool

channel.object.processChannelState(SUSPENDED) # RTO27b: no-op for objects data

# RTO27b: data retained unchanged
ASSERT "name" IN pool["root"].data
ASSERT pool["counter:score@1000"].value == 100
```
Loading