diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 16e082d8e..ec722b242 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -147,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 no update is emitted. - `(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) @@ -529,7 +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` marked as a no-op per [RTLO4b4b](#RTLO4b4b) + - `(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 @@ -805,7 +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` marked as a no-op per [RTLO4b4b](#RTLO4b4b) + - `(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 diff --git a/uts/objects/unit/internal_live_counter.md b/uts/objects/unit/internal_live_counter.md index 73a36b46d..912a15ee3 100644 --- a/uts/objects/unit/internal_live_counter.md +++ b/uts/objects/unit/internal_live_counter.md @@ -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` @@ -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` diff --git a/uts/objects/unit/internal_live_map.md b/uts/objects/unit/internal_live_map.md index 348601398..d28d600bc 100644 --- a/uts/objects/unit/internal_live_map.md +++ b/uts/objects/unit/internal_live_map.md @@ -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` @@ -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` diff --git a/uts/objects/unit/live_object_subscribe.md b/uts/objects/unit/live_object_subscribe.md index 88e683925..7f18099b1 100644 --- a/uts/objects/unit/live_object_subscribe.md +++ b/uts/objects/unit/live_object_subscribe.md @@ -324,6 +324,86 @@ ASSERT updates_b.length == 1 --- +## RTLO4b4c3c - tombstone update on an already-zero counter still fires listeners then deregisters + +**Test ID**: `objects/unit/RTLO4b4c3c/tombstone-zero-value-counter-tears-down-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b4c3c | If LiveObjectUpdate.tombstone is true, deregister all listeners | +| RTLO4b4c3a | Listeners are called with the tombstone update itself before deregistration | +| RTLC14c | The zero-delta tombstone update is NOT a no-op, so it is still delivered | + +Complements `objects/unit/RTLO4b4c3c/tombstone-deregisters-listeners-0` (which tombstones a +populated counter). Here the counter is first driven down to `0`, so tombstoning it produces a +zero-delta diff. Per the RTLC14c tombstone carve-out this update is NOT a no-op: contrast +`objects/unit/RTLO4b4c1/noop-no-trigger-0`, where a genuine noop does not fire the listener at +all. The listeners still fire with the tombstone update and are then deregistered per +RTLO4b4c3c. Tested through Instance#subscribe (RTINS16); the tombstone is identified by +`message.operation.action == "OBJECT_DELETE"`. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates_a = [] +updates_b = [] +control = [] +instance = root.get("score").instance() + +# Drive the counter (100 in the standard pool) down to 0 BEFORE registering the listeners under +# test, so they observe only the tombstone. poll_until(value() == 0) is the quiescence barrier that +# the increment has been applied before we subscribe, so the "-100" update is not seen by them. +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", -100, "40", "remote") +])) +poll_until(root.get("score").value() == 0, timeout: 5s) + +instance.subscribe((event) => updates_a.append(event)) +instance.subscribe((event) => updates_b.append(event)) +``` + +### Test Steps +```pseudo +# OBJECT_DELETE tombstones the already-zero counter (zero-delta diff, RTLC14c → NOT a no-op) +mock_ws.send_to_client(build_object_message("test", [ + build_object_delete("counter:score@1000", "50", "remote") +])) +# Per the Negative-assertion quiescence pattern (helpers/standard_test_pool.md): AWAIT ALL involved +# listeners on this dispatch before asserting either count. +poll_until(updates_a.length >= 1, timeout: 5s) +poll_until(updates_b.length >= 1, timeout: 5s) + +# Both listeners received the tombstone update even though the counter data did not change (0 → 0) +ASSERT updates_a.length == 1 +ASSERT updates_a[0].message.operation.action == "OBJECT_DELETE" +ASSERT updates_b.length == 1 +ASSERT updates_b[0].message.operation.action == "OBJECT_DELETE" + +# Prove deregistration. As in the populated teardown case, a tombstoned object ignores further ops +# (RTLC7e), so neither the deregistered listeners nor a fresh listener on counter:score@1000 could +# ever fire — use a SEPARATE LIVE object (map:profile@1000) as the quiescence barrier. Messages are +# processed in order, so once the control fires, the follow-up "51" has also been processed. +control_inst = root.get("profile").instance() +control_inst.subscribe((event) => control.append(event)) +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 3, "51", "remote") +])) +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:profile@1000", "quiescence_probe", { string: "x" }, "52", "remote") +])) +poll_until(control.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +# Control delivered, so any still-registered original listener would also have run: the tombstone +# deregistered them per RTLO4b4c3c. +ASSERT updates_a.length == 1 +ASSERT updates_b.length == 1 +``` + +--- + ## RTLO4b4d - InstanceSubscriptionEvent.message is populated from source ObjectMessage **Test ID**: `objects/unit/RTLO4b4d/update-has-object-message-0` diff --git a/uts/objects/unit/object_id.md b/uts/objects/unit/object_id.md index 8f51f7bc9..5e010a76c 100644 --- a/uts/objects/unit/object_id.md +++ b/uts/objects/unit/object_id.md @@ -37,9 +37,13 @@ objectId = generateObjectId( ASSERT objectId STARTS WITH "counter:" ASSERT objectId CONTAINS "@1700000000000" parts = objectId.split(":") +# assert the shape before indexing, so a malformed id fails the test rather than +# trapping/throwing on an out-of-range index (relevant for statically-typed SDKs) +ASSERT parts.length == 2 type_part = parts[0] rest = parts[1] hash_and_ts = rest.split("@") +ASSERT hash_and_ts.length == 2 hash_part = hash_and_ts[0] ts_part = hash_and_ts[1] ASSERT type_part == "counter" @@ -148,7 +152,13 @@ objectId = generateObjectId( nonce: "test-nonce-12345678", timestamp: 1700000000000 ) -hash_part = objectId.split(":")[1].split("@")[0] +parts = objectId.split(":") +# assert the shape before indexing, so a malformed id fails the test rather than +# trapping/throwing on an out-of-range index (relevant for statically-typed SDKs) +ASSERT parts.length == 2 +hash_and_ts = parts[1].split("@") +ASSERT hash_and_ts.length == 2 +hash_part = hash_and_ts[0] ``` ### Assertions diff --git a/uts/objects/unit/objects_pool.md b/uts/objects/unit/objects_pool.md index efe94f1a3..dbcb1af72 100644 --- a/uts/objects/unit/objects_pool.md +++ b/uts/objects/unit/objects_pool.md @@ -119,6 +119,70 @@ ASSERT updates[0].objectMessage IS null --- +## RTO4b2a - ATTACHED without HAS_OBJECTS on an already-empty root emits no update + +**Test ID**: `objects/unit/RTO4b2a/reset-of-empty-root-emits-no-update-0` + +| Spec | Requirement | +|------|-------------| +| RTO4b1 | Remove all objects except root | +| RTO4b2a | If no keys were removed (root already empty), the update has no changed keys and is a no-op (RTLM22c/RTLO4b4b), so nothing is emitted | +| RTLM22c | An empty map diff collapses to a no-op | + +Complements `objects/unit/RTO4b/attached-no-objects-synced-0` (which resets a populated root and +emits a `removed` update). Here the root `InternalLiveMap` is already empty, so the RTO4b2 reset +removes no keys: the resulting `LiveMapUpdate` has no changed keys and, per RTLM22c/RTLO4b4b, is a +no-op that must not be delivered to `root` subscribers. + +### Setup +```pseudo +pool = ObjectsPool() +pool["counter:abc@1000"] = InternalLiveCounter(objectId: "counter:abc@1000") +# root is already empty (zero-value InternalLiveMap per RTLM4c) +pool["root"].data = {} +``` + +### Test Steps +```pseudo +updates = [] +pool["root"].subscribe((update) => updates.append(update)) + +pool.processAttached(ProtocolMessage( + action: ATTACHED, + channel: "test", + flags: 0 +)) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "counter:abc@1000" NOT IN pool # RTO4b1: non-root objects are still removed +ASSERT "root" IN pool +ASSERT pool["root"].data == {} +# RTO4b2a: no keys were removed, so the empty update collapses to a no-op and is not delivered +ASSERT updates.length == 0 +``` + +### Liveness control +```pseudo +# Prove the subscription wiring is live: a reset that DOES remove a key still emits, so the +# updates.length == 0 above reflects the empty-root collapse and not a dead subscription. This +# mirrors `objects/unit/RTO4b/attached-no-objects-synced-0`. (Emission at the ObjectsPool tier is +# synchronous — see that case — so no polling is required.) +pool2 = ObjectsPool() +pool2["root"].data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +control = [] +pool2["root"].subscribe((update) => control.append(update)) +pool2.processAttached(ProtocolMessage(action: ATTACHED, channel: "test", flags: 0)) +ASSERT control.length >= 1 +ASSERT control[0].update == { "name": "removed" } +``` + +--- + ## RTO5 - OBJECT_SYNC complete sequence **Test ID**: `objects/unit/RTO5/sync-complete-sequence-0` diff --git a/uts/objects/unit/path_object_subscribe.md b/uts/objects/unit/path_object_subscribe.md index 7e495d54d..fc52d8a15 100644 --- a/uts/objects/unit/path_object_subscribe.md +++ b/uts/objects/unit/path_object_subscribe.md @@ -466,7 +466,7 @@ mock_ws.send_to_client(build_object_message("test", [ mock_ws.send_to_client(build_object_message("test", [ build_counter_inc("counter:new@2000", 10, "100", "remote") ])) -poll_until(events.length >= 1, timeout: 5s) +poll_until(events.length >= 2, timeout: 5s) ``` ### Assertions @@ -477,6 +477,13 @@ FOR event IN events: IF event.object.path() == "score": found_new = true ASSERT found_new == true +// The second dispatch is the increment on the NEW counter — this is what proves the subscription +// followed the path to the replacement object rather than staying bound to the old identity +// (the MAP_SET dispatch alone would already satisfy found_new above) +ASSERT events.length == 2 +ASSERT events[1].object.path() == "score" +ASSERT events[1].message.operation.action == "COUNTER_INC" +ASSERT events[1].message.operation.objectId == "counter:new@2000" ``` --- diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md index d340c671a..6b5be2756 100644 --- a/uts/objects/unit/realtime_object.md +++ b/uts/objects/unit/realtime_object.md @@ -502,6 +502,61 @@ ASSERT root.get("score").value() == 100 --- +## RTO20d4 - Empty synthetic list skips the RTO20e sync wait + +**Test ID**: `objects/unit/RTO20d4/empty-synthetic-list-skips-sync-wait-0` + +**Spec requirement:** When every serial from the `PublishResult` is `null` and thus skipped per RTO20d1, the resulting list of synthetic `ObjectMessages` is empty; there is nothing to apply locally, so `publishAndApply` completes successfully without performing the RTO20e wait. Positive-assertion design: the channel is deliberately moved to `SYNCING` (where a normal write would park in the RTO20e wait, per the RTO20e case) and no sync-completing message is ever delivered — so the operation future resolving at all proves the RTO20e wait was skipped. Nothing is applied locally, so the local value is unchanged. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + mock_ws.send_to_client(build_ack_message(msg.msgSerial, [null])) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +# Move the objects sync state back to SYNCING so a normal publishAndApply would park in +# the RTO20e wait for SYNCED (cf. the RTO20e waits-for-synced case). +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", + flags: HAS_OBJECTS +)) + +# The OBJECT publish is ACKed with an all-null serial list, so per RTO20d1 every synthetic +# ObjectMessage is skipped and the synthetic list is empty. No sync-completing message is +# ever sent: if the RTO20e wait were performed this future would never resolve. +AWAIT root.get("score").increment(10) +``` + +### Assertions +```pseudo +# Resolution despite the channel never reaching SYNCED proves the RTO20e wait was skipped. +ASSERT root.get("score").value() == 100 +``` + +--- + ## RTO20e - publishAndApply waits for SYNCED during SYNCING **Test ID**: `objects/unit/RTO20e/waits-for-synced-0`