diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 227d6f159..22f6792fe 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -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). @@ -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) @@ -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 `:` - `(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 `` and a `` --- the client library must handle the `OBJECT_SYNC` as if the `channelSerial` were absent per [RTO5a5](#RTO5a5), and should log a warning - `(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 @@ -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: @@ -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` @@ -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 @@ -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 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 ada2c79c9..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` @@ -206,6 +270,74 @@ ASSERT "counter:new@1000" IN pool --- +## RTO5a5 - OBJECT_SYNC with no channelSerial is a single-message sync + +**Test ID**: `objects/unit/RTO5a5/absent-channel-serial-0` + +**Spec requirement:** An `OBJECT_SYNC` may be sent with no `channelSerial` attribute. In this case the +sync data is entirely contained within the single `ProtocolMessage`: the objects are applied and the +sync sequence completes (`SYNCED`) without waiting for a cursor-empty `channelSerial` (RTO5a4). This is +the baseline that the RTO5a6 malformed-channelSerial case defers to. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +# No channelSerial: the whole sync is contained in this one message (RTO5a5) +pool.processObjectSync(build_object_sync_message("test", null, [ + build_object_state("counter:new@1000", {"aaa": "t:0"}, { counter: { count: 99 } }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "counter:new@1000" IN pool +``` + +--- + +## RTO5a6 - Malformed channelSerial is treated as absent + +**Test ID**: `objects/unit/RTO5a6/malformed-channel-serial-treated-as-absent-0` + +**Spec requirement:** If the `channelSerial` is present but malformed --- it does not contain the `:` +separator required by RTO5a1 and so cannot be split into a `` and a `` --- +the `OBJECT_SYNC` must be handled as if the `channelSerial` were absent per RTO5a5 (data applied, sync +completes `SYNCED`), and a warning should be logged. This must stay distinct from the RTO5a5 baseline. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +# "malformedserialnocolon" has no ':' separator, so it cannot be parsed per RTO5a1; RTO5a6 +# requires handling it as if the channelSerial were absent (RTO5a5). +pool.processObjectSync(build_object_sync_message("test", "malformedserialnocolon", [ + build_object_state("counter:new@1000", {"aaa": "t:0"}, { counter: { count: 99 } }) +])) +``` + +### Assertions +```pseudo +# Treated as absent (RTO5a5): the message was applied and the sync ended. +ASSERT pool.syncState == SYNCED +ASSERT "counter:new@1000" IN pool +``` + +--- + ## RTO5f2a - Partial object state merge for maps **Test ID**: `objects/unit/RTO5f2a/partial-map-merge-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 524724033..c00e07968 100644 --- a/uts/objects/unit/realtime_object.md +++ b/uts/objects/unit/realtime_object.md @@ -188,6 +188,143 @@ ASSERT root.path == [] --- +## RTO23c1 - get() fails when channel enters DETACHED during sync wait + +**Test ID**: `objects/unit/RTO23c1/fails-on-channel-detached-0` + +**Spec requirement:** If the channel enters `DETACHED`/`SUSPENDED`/`FAILED` while `get` waits for the +sync state to transition to `SYNCED`, the `get` operation must fail with an `ErrorInfo` error with +`code` `92008` and `statusCode` `400`. + +This is the get()-side counterpart of RTO20e1 (which covers the same wait-failure for +`publishAndApply`). The channel is detached **client-side** while `get` waits for SYNCED — an +unsolicited server DETACHED would trigger an immediate re-attach (RTL13a) in a compliant SDK, so the +channel would never observably stay DETACHED. A solicited `channel.detach()` does not trigger RTL13a; +the shared mock answers the outbound DETACH with DETACHED. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +# Move the objects sync state back to SYNCING so a fresh get() must wait (RTO23c) +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", + flags: HAS_OBJECTS +)) + +get_future = channel.object.get() + +# While still SYNCING the get() cannot complete — it parks in the RTO23c wait for SYNCED +ASSERT get_future IS NOT complete + +# A client-side detach then moves the channel to DETACHED +AWAIT channel.detach() + +AWAIT get_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92008 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO23c1 - get() fails when channel enters SUSPENDED during sync wait + +**Test ID**: `objects/unit/RTO23c1/fails-on-channel-suspended-0` + +**Spec requirement:** If the channel enters `DETACHED`/`SUSPENDED`/`FAILED` while `get` waits for the +sync state to transition to `SYNCED`, the `get` operation must fail with an `ErrorInfo` error with +`code` `92008` and `statusCode` `400`. + +SUSPENDED is a connection-level state that a channel-level mock cannot drive, so — exactly as the RTO27 +tests do — this case drives the RealtimeObject's internal channel-state handler directly via +`channel.object.processChannelState(SUSPENDED)` (ably-js `RealtimeObject.actOnChannelState`, ably-java +`DefaultRealtimeObject.handleStateChange(state, false)`). RTO27b retains objects *data* on SUSPENDED, +but RTO23c1 must still fail any in-flight `get` sync wait. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +# Move the objects sync state back to SYNCING so a fresh get() must wait (RTO23c) +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", + flags: HAS_OBJECTS +)) + +get_future = channel.object.get() +ASSERT get_future IS NOT complete + +# The mock cannot drive SUSPENDED; drive the channel-state handler directly (as RTO27 does) +channel.object.processChannelState(SUSPENDED) + +AWAIT get_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92008 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO23c1 - get() fails with cause when channel enters FAILED during sync wait + +**Test ID**: `objects/unit/RTO23c1/fails-on-channel-failed-0` + +**Spec requirement:** If the channel enters `DETACHED`/`SUSPENDED`/`FAILED` while `get` waits for the +sync state to transition to `SYNCED`, the `get` operation must fail with an `ErrorInfo` error with +`code` `92008`, `statusCode` `400`, and `cause` set to the `RealtimeChannel.errorReason` when it is set. + +Mirrors the RTO20e1 FAILED case (an injected channel ERROR moves the channel to FAILED while `get` +waits for SYNCED) and additionally asserts the `cause`: RTO23c1 requires `cause` to be set to the +channel's `errorReason` when present, which here is the injected FAILED error. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +# Move the objects sync state back to SYNCING so a fresh get() must wait (RTO23c) +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", + flags: HAS_OBJECTS +)) + +get_future = channel.object.get() +ASSERT get_future IS NOT complete + +# A channel ERROR moves the channel to FAILED and sets its errorReason +mock_ws.send_to_client(ProtocolMessage( + action: ERROR, channel: "test", + error: { code: 90000, statusCode: 400, message: "Channel failed" } +)) + +AWAIT get_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92008 +ASSERT error.statusCode == 400 +# RTO23c1 - cause is set to the channel's errorReason (the injected FAILED error) +ASSERT error.cause.code == 90000 +``` + +--- + ## RTO15 - publish sends OBJECT ProtocolMessage **Test ID**: `objects/unit/RTO15/publish-sends-object-pm-0` @@ -365,6 +502,117 @@ 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 +``` + +--- + +## RTO20d4 - Mixed null/non-null serials still applies the non-null operation + +**Test ID**: `objects/unit/RTO20d4/mixed-null-serials-applies-non-null-0` + +**Spec requirement:** The RTO20d4 emptiness check runs once, *after* the iteration over all `ObjectMessages` in the provided argument is complete — not per-iteration. With a serial list such as `[null, ]`, the first iteration is skipped per RTO20d1 (leaving the synthetic list momentarily empty), but iteration must continue and synthesize the second, valid operation; the emptiness check then sees a non-empty list and `publishAndApply` applies it (it does not complete early). A `root.set(key, LiveCounter.create(...))` publishes two `ObjectMessages` per RTLM20h1 — `[COUNTER_CREATE, MAP_SET]` — so ACKing with `[null, ack_serial(msg.msgSerial, 1)]` skips the CREATE and applies the `MAP_SET` on `root`. The channel is kept SYNCED so the RTO20e wait is not a factor (the list is non-empty, so RTO20d4's early completion does not apply and the normal RTO20e path runs). The CREATE being skipped means the new counter is never added to the pool, so the `"child"` entry is a dangling reference and `root.get("child")` is undefined per RTLM5d2f1; the positive proof that the second (index-1) operation was applied is that `"child"` is present among the map's keys (RTLM11d3a/RTLM12) and the map size grew. + +### 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: + # The set() publishes two ObjectMessages: [COUNTER_CREATE, MAP_SET] (RTLM20h1). + # ACK the first with a null serial (skipped per RTO20d1) and the second with a + # valid serial (applied). If RTO20d4 were evaluated per-iteration, publishAndApply + # would complete after the first (null) message and never synthesize/apply the second. + mock_ws.send_to_client(build_ack_message(msg.msgSerial, [null, ack_serial(msg.msgSerial, 1)])) + } +) +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() +# Channel is SYNCED after the OBJECT_SYNC above; the RTO20e wait is a no-op. +``` + +### Test Steps +```pseudo +# Two-message publish (RTLM20h1). The publish resolves only if the loop completed and the +# non-empty list was applied — i.e. RTO20d4 did NOT complete early after the null first serial. +AWAIT root.set("child", LiveCounter.create(0)) +``` + +### Assertions +```pseudo +# The MAP_SET (index 1, valid serial) WAS applied: the "child" key is now present in root. +# (Its value resolves to undefined per RTLM5d2f1 because the COUNTER_CREATE at index 0 was +# intentionally skipped and the counter is not in the pool — expected; the key's presence is +# the positive proof the second operation applied.) +ASSERT Array.from(root.keys()) CONTAINS "child" +ASSERT root.size() == 8 # 7 standard-pool entries + the applied "child" entry +``` + +--- + ## RTO20e - publishAndApply waits for SYNCED during SYNCING **Test ID**: `objects/unit/RTO20e/waits-for-synced-0`