Skip to content
11 changes: 8 additions & 3 deletions specifications/objects-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Objects feature enables clients to store shared data as "objects" on a channel.
- `(RTO23b)` This clause has been replaced by [RTO23e](#RTO23e)
- `(RTO23e)` Perform the *ensure-active-channel* procedure ([RTL33](../features#RTL33)) on the underlying `RealtimeChannel`. If the procedure fails, the `get` function must reject with the same `ErrorInfo` that caused the procedure to fail
- `(RTO23c)` If the [RTO17](#RTO17) sync state is not `SYNCED`, waits for the sync state to transition to `SYNCED`
- `(RTO23c1)` If the channel enters the `DETACHED`, `SUSPENDED`, or `FAILED` state while waiting for the sync state to transition to `SYNCED`, the `get` operation must fail with an `ErrorInfo` error with `code` `92008`, a `statusCode` of `400`, a `message` stating that the object could not be retrieved due to the channel entering the respective state whilst waiting for objects sync to complete, and `cause` set to the `RealtimeChannel.errorReason` if it is set. This applies regardless of the state the channel transitioned from (for example, a channel that enters `FAILED` from `SUSPENDED` must also fail the waiting `get` operation), and mirrors [RTO20e1](#RTO20e1), the equivalent failure for `publishAndApply`
- `(RTO23d)` Returns a new `PathObject` ([RTPO1](#RTPO1)) with `path` ([RTPO2a](#RTPO2a)) set to an empty list and `root` ([RTPO2b](#RTPO2b)) set to the `InternalLiveMap` with id `root` from the internal `ObjectsPool`
- `(RTO11)` This clause has been replaced by [RTLMV3](#RTLMV3).
- `(RTO11a)` This clause has been replaced by [RTLMV3](#RTLMV3).
Expand Down Expand Up @@ -146,7 +147,7 @@ Objects feature enables clients to store shared data as "objects" on a channel.
- `(RTO4b)` If the `HAS_OBJECTS` flag is 0 or there is no `flags` field, the sync sequence must be considered complete immediately, and the client library must perform the following actions in order:
- `(RTO4b1)` All objects except the one with id `root` must be removed from the internal `ObjectsPool`
- `(RTO4b2)` The data for the `InternalLiveMap` with id `root` must be set to the value described in [RTLM4c](#RTLM4c). Note that the client SDK must not create a new `InternalLiveMap` instance with id `root`; it must only clear the internal data of the existing `InternalLiveMap` with id `root`
- `(RTO4b2a)` Emit a `LiveMapUpdate` object for the `InternalLiveMap` with ID `root`, with `LiveMapUpdate.update` consisting of entries for the keys that were removed, each set to `removed`, and without populating `LiveMapUpdate.objectMessage`
- `(RTO4b2a)` Emit a `LiveMapUpdate` object for the `InternalLiveMap` with ID `root`, with `LiveMapUpdate.update` consisting of entries for the keys that were removed, each set to `removed`, and without populating `LiveMapUpdate.objectMessage`. Only the keys of non-tombstoned entries are reported as `removed`, consistent with the non-tombstoned-visibility rule in [RTLM22b](#RTLM22b): entries that were already tombstoned were not part of the user-visible map data, so their removal is not reported. If no keys were removed (that is, the `root` map was already empty), the computed `LiveMapUpdate.update` contains no changed keys and is therefore a no-op per [RTLM22c](#RTLM22c) ([RTLO4b4b](#RTLO4b4b)), so per [RTLO4b4c1](#RTLO4b4c1) it is not delivered to subscribers.
- `(RTO4b3)` The `SyncObjectsPool` must be cleared
- `(RTO4b5)` This clause has been replaced by [RTO4d](#RTO4d)
- `(RTO4b4)` Perform the actions for objects sync completion as described in [RTO5c](#RTO5c)
Expand All @@ -161,6 +162,7 @@ Objects feature enables clients to store shared data as "objects" on a channel.
- `(RTO5a3)` If the sequence id matches the previously received sequence id, the client library should continue the sync process
- `(RTO5a4)` The objects sync sequence for that sequence identifier is considered complete once the cursor is empty; that is when the `channelSerial` looks like `<sequence id>:`
- `(RTO5a5)` An `OBJECT_SYNC` may also be sent with no `channelSerial` attribute. In this case, the sync data is entirely contained within the `ProtocolMessage`
- `(RTO5a6)` If the `channelSerial` is present but malformed --- that is, it does not contain the `:` separator required by [RTO5a1](#RTO5a1) and so cannot be split into a `<sequence id>` and a `<cursor value>` --- the client library must handle the `OBJECT_SYNC` as if the `channelSerial` were absent per [RTO5a5](#RTO5a5), and should log a warning
Comment thread
sacOO7 marked this conversation as resolved.
- `(RTO5b)` This clause has been replaced by [RTO5f](#RTO5f)
- `(RTO5f)` During the sync sequence, `ObjectMessages` from incoming `OBJECT_SYNC` `ProtocolMessages` must be temporarily stored in the internal `SyncObjectsPool`, keyed by `ObjectMessage.object.objectId`. The `SyncObjectsPool` stores one `ObjectMessage` per `objectId`, which may represent merged state from multiple incoming messages. For each `ObjectMessage` in the incoming `OBJECT_SYNC` `ProtocolMessage`, let `ObjectState` be `ObjectMessage.object`:
- `(RTO5f3)` If neither `ObjectState.map` nor `ObjectState.counter` is present on the incoming message, log a warning that a state message with an unsupported object type was received and skip the incoming message
Expand Down Expand Up @@ -192,9 +194,9 @@ 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 (the `ATTACHED` transition is handled by [RTO4](#RTO4); for the effect of these transitions on an in-progress `publishAndApply`, see [RTO20e1](#RTO20e1)):
- `(RTO27)` When the channel transitions to a state other than `ATTACHED`, the client library must manage the stored objects data as follows (the `ATTACHED` transition is handled by [RTO4](#RTO4); for the effect of these transitions on an in-progress `publishAndApply` or `get`, see [RTO20e1](#RTO20e1) and [RTO23c1](#RTO23c1)):
- `(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
- `(RTO27a1)` For every object in the internal `ObjectsPool`, clear its internal data, resetting it to that of a new empty object of its type (an empty map per [RTLM4c](#RTLM4c), or a counter with `data` `0` per [RTLC4b](#RTLC4b)), 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 any other state (for example `SUSPENDED`, `INITIALIZED`, `ATTACHING`, or `DETACHING`), the client library must retain the stored objects data unchanged. In the `SUSPENDED` case in particular, 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:
Expand Down Expand Up @@ -277,6 +279,7 @@ Objects feature enables clients to store shared data as "objects" on a channel.
- `(RTO20d2a)` `ObjectMessage.serial` to the serial from the `PublishResult`
- `(RTO20d2b)` `ObjectMessage.siteCode` to the [CD2j](../features#CD2j) `ConnectionDetails.siteCode`
- `(RTO20d3)` Add the synthetic `ObjectMessage` to the list
- `(RTO20d4)` Once the iteration over all `ObjectMessages` in the provided argument is complete, if the resulting list of synthetic `ObjectMessages` is empty (for example because every serial from the `PublishResult` was `null` and thus skipped per [RTO20d1](#RTO20d1)), there is nothing to apply locally, so the `publishAndApply` operation completes successfully without performing the [RTO20e](#RTO20e) wait
- `(RTO20e)` If the [RTO17](#RTO17) sync state is not `SYNCED`, wait for the sync state to transition to `SYNCED`
- `(RTO20e1)` If the channel enters the `DETACHED`, `SUSPENDED`, or `FAILED` state while waiting for the sync state to transition to `SYNCED`, the `publishAndApply` operation must fail with an `ErrorInfo` error with `code` `92008`, a `statusCode` of `400`, a `message` stating that the operation could not be applied locally due to the channel entering the respective state whilst waiting for objects sync to complete, and `cause` set to the `RealtimeChannel.errorReason` if it is set
- `(RTO20f)` Apply the synthetic `ObjectMessages` as described in [RTO9](#RTO9), passing `source` as `LOCAL`
Expand Down Expand Up @@ -526,6 +529,7 @@ Objects feature enables clients to store shared data as "objects" on a channel.
- `(RTLC14a1)` `previousData` `Number` - the previous `data` value
- `(RTLC14a2)` `newData` `Number` - the new `data` value
- `(RTLC14b)` Return a `LiveCounterUpdate` object with `LiveCounterUpdate.update.amount` set to `newData - previousData`
- `(RTLC14c)` As an exception to [RTLC14b](#RTLC14b): if `newData` equals `previousData` (that is, the computed delta is `0`), the counter data did not change, so instead of returning an update return a `LiveCounterUpdate` object with `LiveCounterUpdate.noop` set to `true` ([RTLO4b4b](#RTLO4b4b)), as in [RTLC9h](#RTLC9h). This exception must not be applied when the diff is computed for a tombstone per [RTLO4e5](#RTLO4e5): the resulting tombstone update ([RTLO4b4e](#RTLO4b4e)) must not be marked as a no-op, so that it is still delivered — driving the [RTLO4b4c3c](#RTLO4b4c3c) listener teardown — even when the counter data was already `0`.

### InternalLiveMap

Expand Down Expand Up @@ -801,6 +805,7 @@ Objects feature enables clients to store shared data as "objects" on a channel.
- `(RTLM22b1)` For each key that exists in the non-tombstoned entries of `previousData` but does not exist in the non-tombstoned entries of `newData`, add the key to `LiveMapUpdate.update` with the value `removed`
- `(RTLM22b2)` For each key that exists in the non-tombstoned entries of `newData` but does not exist in the non-tombstoned entries of `previousData`, add the key to `LiveMapUpdate.update` with the value `updated`
- `(RTLM22b3)` For each key that exists in the non-tombstoned entries of both `previousData` and `newData`, perform a deep comparison of the `data` attributes from `previousData` and `newData`. If the data values differ, add the key to `LiveMapUpdate.update` with the value `updated`
- `(RTLM22c)` As an exception to [RTLM22b](#RTLM22b): if the `LiveMapUpdate.update` computed in [RTLM22b](#RTLM22b) contains no changed keys (it is empty), no map key actually changed, so instead of returning an update return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true` ([RTLO4b4b](#RTLO4b4b)), as in [RTLM16b](#RTLM16b). This exception must not be applied when the diff is computed for a tombstone per [RTLO4e5](#RTLO4e5): the resulting tombstone update ([RTLO4b4e](#RTLO4b4e)) must not be marked as a no-op, so that it is still delivered — driving the [RTLO4b4c3c](#RTLO4b4c3c) listener teardown — even when the map already had no non-tombstoned entries.

### LiveCounter

Expand Down
69 changes: 69 additions & 0 deletions uts/objects/unit/internal_live_counter.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,47 @@ ASSERT update.objectMessage == msg

---

## RTLO5, RTLO4e5 - OBJECT_DELETE on an already-zero counter still emits a non-noop tombstone update

**Test ID**: `objects/unit/RTLO5/tombstone-zero-value-counter-emits-update-0`

| Spec | Requirement |
|------|-------------|
| RTLO4e5 | Compute the tombstone diff per RTLC14 |
| RTLC14c | The zero-delta noop exception must NOT be applied for a tombstone diff; the update is delivered to drive the RTLO4b4c3c listener teardown |
| RTLO4e6 | Set tombstone flag on the update |
| RTLO4e7 | Set objectMessage on the update |

Complements `objects/unit/RTLO5/object-delete-tombstones-0` (which tombstones a populated
counter). Here the counter data is already `0`, so the tombstone diff (`previousData` `0`,
`newData` `0`) is a zero delta. Per the RTLC14c tombstone carve-out this update must NOT be
marked as a no-op — it must still be delivered so the RTLO4b4c3c listener teardown runs.

### Setup
```pseudo
counter = InternalLiveCounter(objectId: "counter:abc@1000")
counter.data = 0
counter.siteTimeserials = { "site1": "00" }
```

### Test Steps
```pseudo
msg = build_object_delete("counter:abc@1000", "01", "site1", 1700000000000)
update = counter.applyOperation(msg, source: CHANNEL)
```

### Assertions
```pseudo
ASSERT counter.isTombstone == true
ASSERT counter.data == 0
ASSERT update.noop == false
ASSERT update.tombstone == true
ASSERT update.update.amount == 0
ASSERT update.objectMessage == msg
```

---

## RTLC7e - Operations on tombstoned counter are rejected

**Test ID**: `objects/unit/RTLC7e/tombstoned-reject-ops-0`
Expand Down Expand Up @@ -783,6 +824,34 @@ ASSERT update.objectMessage == state_msg

---

## RTLC14c - Zero-delta diff is a no-op

**Test ID**: `objects/unit/RTLC14c/zero-delta-diff-is-noop-0`

**Spec requirement:** As an exception to RTLC14b, when `newData` equals `previousData` the computed delta is `0`, so the diff returns a `LiveCounterUpdate` marked as a no-op per RTLO4b4b. A no-op update is never delivered to subscribers (RTLO4b4c1), so at the internal tier the flake-free proxy for "no event fires" is asserting `update.noop == true`.

### Setup
```pseudo
counter = InternalLiveCounter(objectId: "counter:abc@1000")
counter.data = 100
```

### Test Steps
```pseudo
state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, {
counter: { count: 100 }
})
update = counter.replaceData(state_msg)
```

### Assertions
```pseudo
ASSERT update.noop == true
ASSERT counter.data == 100
```

---

## RTLC8, RTLC16 - COUNTER_CREATE then COUNTER_INC accumulates

**Test ID**: `objects/unit/RTLC8/create-then-inc-0`
Expand Down
83 changes: 83 additions & 0 deletions uts/objects/unit/internal_live_map.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,54 @@ ASSERT update.objectMessage == msg

---

## RTLO5, RTLO4e5 - OBJECT_DELETE on a map with no non-tombstoned entries still emits a non-noop tombstone update

**Test ID**: `objects/unit/RTLO5/tombstone-empty-map-emits-update-0`

| Spec | Requirement |
|------|-------------|
| RTLO4e5 | Compute the tombstone diff per RTLM22 |
| RTLM22c | The empty-diff noop exception must NOT be applied for a tombstone diff; the update is delivered to drive the RTLO4b4c3c listener teardown |
| RTLO4e6 | Set tombstone flag on the update |
| RTLO4e7 | Set objectMessage on the update |

Complements `objects/unit/RTLO5/object-delete-tombstones-map-0` (which tombstones a map with
live entries). Here every entry is already tombstoned, so the map has no non-tombstoned entries
and the tombstone diff (per RTLM22b, which considers only non-tombstoned entries) contains no
changed keys. Per the RTLM22c tombstone carve-out this empty update must NOT be marked as a
no-op — it must still be delivered so the RTLO4b4c3c listener teardown runs.

Uses a non-root map: an `OBJECT_DELETE` targeting `root` is rejected per RTLO4e10
(see `objects/unit/RTLO4e10/object-delete-root-noop-0`).

### Setup
```pseudo
map = InternalLiveMap(objectId: "map:test@1000", semantics: "LWW")
map.data = {
"name": { data: { string: "Alice" }, timeserial: "01", tombstone: true, tombstonedAt: 1600000000000 },
"age": { data: { number: 30 }, timeserial: "01", tombstone: true, tombstonedAt: 1600000000000 }
}
map.siteTimeserials = { "site1": "00" }
```

### Test Steps
```pseudo
msg = build_object_delete("map:test@1000", "01", "site1", 1700000000000)
update = map.applyOperation(msg, source: CHANNEL)
```

### Assertions
```pseudo
ASSERT map.isTombstone == true
ASSERT map.data == {}
ASSERT update.noop == false
ASSERT update.tombstone == true
ASSERT update.update == {}
ASSERT update.objectMessage == msg
```

---

## RTLO4e10 - OBJECT_DELETE targeting root is rejected

**Test ID**: `objects/unit/RTLO4e10/object-delete-root-noop-0`
Expand Down Expand Up @@ -962,6 +1010,41 @@ ASSERT "now_dead" NOT IN update.update

---

## RTLM22c - Empty diff is a no-op

**Test ID**: `objects/unit/RTLM22c/empty-diff-is-noop-0`

**Spec requirement:** As an exception to RTLM22b, when the computed `LiveMapUpdate.update` contains no changed keys the diff returns a `LiveMapUpdate` marked as a no-op per RTLO4b4b. A no-op update is never delivered to subscribers (RTLO4b4c1), so at the internal tier the flake-free proxy for "no event fires" is asserting `update.noop == true`. Here the map's non-tombstoned entries before and after `replaceData` are identical under the RTLM22b comparison rules (same key `name`, same `data`; only `timeserial` differs, which is not compared), so no key changed.

### Setup
```pseudo
map = InternalLiveMap(objectId: "root", semantics: "LWW")
map.data = {
"name": { data: { string: "alice" }, timeserial: "01", tombstone: false }
}
```

### Test Steps
```pseudo
state_msg = build_object_state("root", {"site1": "02"}, {
map: {
semantics: "LWW",
entries: {
"name": { data: { string: "alice" }, timeserial: "02", tombstone: false }
}
}
})
update = map.replaceData(state_msg)
```

### Assertions
```pseudo
ASSERT update.noop == true
ASSERT map.data["name"].data == { string: "alice" }
```

---

## RTLM15d4 - Unsupported action is discarded

**Test ID**: `objects/unit/RTLM15d4/unsupported-action-0`
Expand Down
Loading
Loading