Skip to content
Open
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
6 changes: 3 additions & 3 deletions specifications/objects-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

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
80 changes: 80 additions & 0 deletions uts/objects/unit/live_object_subscribe.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
12 changes: 11 additions & 1 deletion uts/objects/unit/object_id.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions uts/objects/unit/objects_pool.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading
Loading