Skip to content

Commit 9a9152f

Browse files
sacOO7claude
andcommitted
Fixed ably-js parity issues in path-based liveobjects API implementation
- PathObject path parsing: exact port of the ably-js escape algorithm (backslash before a non-dot is kept literally, so an escaped backslash no longer suppresses the dot split; trailing backslash kept as-is) - PathObjectSubscriptionRegister: deliver one event per full path to the updated object, so an object reachable via several covered paths notifies subscribers once per path (mirrors liveobject.ts#_notifyPathSubscriptions) - size()/keys()/entries() on map path objects and size() on map instances now exclude entries with unresolvable (dangling/tombstoned) object references, per RTLM10d/RTLM14 - Added parity tests for escaped-backslash parsing, multi-path event delivery, and unresolvable-reference filtering Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6ba806c commit 9a9152f

5 files changed

Lines changed: 86 additions & 37 deletions

File tree

liveobjects/src/main/kotlin/io/ably/lib/object/PathObjectSubscriptionRegister.kt

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,6 @@ internal class PathObjectSubscriptionRegister(private val bridge: ObjectsBridge)
6363
val fullPaths = PathFinder.findFullPaths(bridge, objectId)
6464
if (fullPaths.isEmpty()) return // object not reachable from root
6565

66-
val candidatePaths = fullPaths.flatMap { fullPath ->
67-
listOf(fullPath) + updatedKeys.map { key -> fullPath + key }
68-
}
69-
7066
val publicMessage = message?.let {
7167
try {
7268
it.toPublicMessage(bridge.channelName)
@@ -76,17 +72,23 @@ internal class PathObjectSubscriptionRegister(private val bridge: ObjectsBridge)
7672
}
7773
}
7874

79-
for (entry in subscriptions.values) {
80-
// first candidate covered by this subscription wins (priority order)
81-
val coveredPath = candidatePaths.firstOrNull { coversPath(entry.path, entry.depth, it) } ?: continue
82-
val event = DefaultPathObjectSubscriptionEvent(
83-
DefaultPathObject(bridge, coveredPath), // RTPO19e1
84-
publicMessage, // RTPO19e2
85-
)
86-
try {
87-
entry.listener.onUpdated(event)
88-
} catch (t: Throwable) {
89-
Log.e(tag, "Error in PathObjectListener callback", t)
75+
// one event per full path to the object (an object reachable via several
76+
// covered paths notifies once per path), exactly like ably-js
77+
// liveobject.ts#_notifyPathSubscriptions
78+
for (fullPath in fullPaths) {
79+
val candidatePaths = listOf(fullPath) + updatedKeys.map { key -> fullPath + key }
80+
for (entry in subscriptions.values) {
81+
// first candidate covered by this subscription wins (priority order)
82+
val coveredPath = candidatePaths.firstOrNull { coversPath(entry.path, entry.depth, it) } ?: continue
83+
val event = DefaultPathObjectSubscriptionEvent(
84+
DefaultPathObject(bridge, coveredPath), // RTPO19e1
85+
publicMessage, // RTPO19e2
86+
)
87+
try {
88+
entry.listener.onUpdated(event)
89+
} catch (t: Throwable) {
90+
Log.e(tag, "Error in PathObjectListener callback", t)
91+
}
9092
}
9193
}
9294
}

liveobjects/src/main/kotlin/io/ably/lib/object/instance/DefaultLiveMapInstance.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,10 @@ internal class DefaultLiveMapInstance(
6464
/** Spec: RTINS8 */
6565
override fun values(): Iterable<Instance> = entries().map { it.value }
6666

67-
/** Spec: RTINS9 / RTTS10a - non-null (the wrapped value is always a map) */
67+
/** Spec: RTINS9 / RTTS10a - non-null; counts resolvable entries only (RTLM10d/RTLM14) */
6868
override fun size(): Long {
6969
bridge.throwIfInvalidAccessApiConfiguration() // RTO25
70-
return mapNodeOrThrow().entries().size.toLong()
70+
return mapNodeOrThrow().entries().count { (_, data) -> data.resolve(bridge) != null }.toLong()
7171
}
7272

7373
/** Spec: RTINS12 */

liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultBasePathObject.kt

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -101,33 +101,38 @@ internal abstract class DefaultBasePathObject(
101101

102102
internal companion object {
103103
/**
104-
* Parses a dot-delimited path string into segments; a backslash-escaped
105-
* dot (`\.`) is a literal dot within a segment.
104+
* Parses a dot-delimited path string into segments: splits on unescaped
105+
* dots; a backslash-escaped dot (`\.`) is a literal dot within a segment;
106+
* a backslash before any other character is kept as-is. Exact port of the
107+
* ably-js algorithm (pathobject.ts#at) so the two SDKs agree on every
108+
* input, including escaped backslashes and trailing backslashes.
106109
*
107110
* Spec: RTPO6 (and the inverse of RTPO4b)
108111
*/
109112
internal fun parsePath(path: String): List<String> {
110113
val segments = mutableListOf<String>()
111114
val current = StringBuilder()
112-
var i = 0
113-
while (i < path.length) {
114-
val c = path[i]
115-
when {
116-
c == '\\' && i + 1 < path.length && path[i + 1] == '.' -> {
117-
current.append('.')
118-
i += 2
119-
}
120-
c == '.' -> {
115+
var escaping = false
116+
for (c in path) {
117+
if (escaping) {
118+
// keep the escape character if not escaping a dot
119+
if (c != '.') current.append('\\')
120+
current.append(c)
121+
escaping = false
122+
continue
123+
}
124+
when (c) {
125+
'\\' -> escaping = true
126+
'.' -> {
121127
segments.add(current.toString())
122128
current.setLength(0)
123-
i++
124-
}
125-
else -> {
126-
current.append(c)
127-
i++
128129
}
130+
else -> current.append(c)
129131
}
130132
}
133+
if (escaping) {
134+
current.append('\\')
135+
}
131136
segments.add(current.toString())
132137
return segments
133138
}

liveobjects/src/main/kotlin/io/ably/lib/object/path/DefaultLiveMapPathObject.kt

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import io.ably.lib.`object`.invalidInputError
1212
import io.ably.lib.`object`.objectDataFrom
1313
import io.ably.lib.`object`.path.types.LiveMapPathObject
1414
import io.ably.lib.`object`.pathNotResolvedError
15+
import io.ably.lib.`object`.resolve
1516
import io.ably.lib.`object`.typeMismatchError
1617
import io.ably.lib.`object`.value.LiveMapValue
1718
import java.util.AbstractMap
@@ -36,6 +37,14 @@ internal class DefaultLiveMapPathObject(
3637

3738
private fun mapNodeOrNull(): MapNode? = (resolve() as? ResolvedValue.MapRef)?.map
3839

40+
/**
41+
* Keys of the resolved map whose entries themselves resolve - entries
42+
* referencing missing/tombstoned objects are excluded, matching the
43+
* underlying map semantics (RTLM11d2/RTLM14).
44+
*/
45+
private fun resolvableKeys(node: MapNode): List<String> =
46+
node.entries().filter { (_, data) -> data.resolve(bridge) != null }.map { it.key }
47+
3948
/** Spec: RTPO5 - purely navigational, no resolution performed */
4049
override fun get(key: String): PathObject = DefaultPathObject(bridge, pathSegments + key)
4150

@@ -47,7 +56,7 @@ internal class DefaultLiveMapPathObject(
4756
override fun entries(): Iterable<Map.Entry<String, PathObject>> {
4857
bridge.throwIfInvalidAccessApiConfiguration() // RTPO9a / RTO25
4958
val node = mapNodeOrNull() ?: return emptyList()
50-
return node.entries().keys.map { key ->
59+
return resolvableKeys(node).map { key ->
5160
AbstractMap.SimpleImmutableEntry<String, PathObject>(key, get(key)) // child paths as if by get(key)
5261
}
5362
}
@@ -56,16 +65,16 @@ internal class DefaultLiveMapPathObject(
5665
override fun keys(): Iterable<String> {
5766
bridge.throwIfInvalidAccessApiConfiguration() // RTPO10a / RTO25
5867
val node = mapNodeOrNull() ?: return emptyList()
59-
return node.entries().keys.toList()
68+
return resolvableKeys(node)
6069
}
6170

6271
/** Spec: RTPO11 */
6372
override fun values(): Iterable<PathObject> = entries().map { it.value }
6473

65-
/** Spec: RTPO12 - null when the path does not resolve to a map */
74+
/** Spec: RTPO12 - null when the path does not resolve to a map; counts resolvable entries (RTLM10d) */
6675
override fun size(): Long? {
6776
bridge.throwIfInvalidAccessApiConfiguration() // RTPO12a / RTO25
68-
return mapNodeOrNull()?.entries()?.size?.toLong()
77+
return mapNodeOrNull()?.let { resolvableKeys(it).size.toLong() }
6978
}
7079

7180
/** Spec: RTPO15 */

liveobjects/src/test/kotlin/io/ably/lib/object/unit/PathApiTest.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ internal class PathApiTest {
4848
// RTPO6 - parsing honours the escape
4949
assertEquals(listOf("a.b", "c"), DefaultBasePathObject.parsePath("a\\.b.c"))
5050
assertEquals(listOf("users", "emma"), DefaultBasePathObject.parsePath("users.emma"))
51+
// ably-js parity: a backslash NOT escaping a dot is kept as-is, so an
52+
// escaped backslash before a dot does not suppress the split
53+
assertEquals(listOf("a\\\\", "b"), DefaultBasePathObject.parsePath("a\\\\.b"))
54+
// ably-js parity: trailing backslash is kept literally
55+
assertEquals(listOf("x\\"), DefaultBasePathObject.parsePath("x\\"))
5156
}
5257

5358
@Test
@@ -164,6 +169,34 @@ internal class PathApiTest {
164169
assertEquals(1, events.size)
165170
}
166171

172+
@Test
173+
fun `object reachable via several covered paths notifies once per path`() {
174+
val bridge = graphBridge()
175+
// second reference to the same counter directly under root
176+
(bridge.nodes["root"] as FakeMapNode).data["topScore"] = WireObjectData(objectId = "counter:score@1")
177+
178+
val events = mutableListOf<String>()
179+
DefaultLiveMapPathObject(bridge, emptyList()).subscribe { events.add(it.getObject().path()) }
180+
181+
bridge.notifyUpdated("counter:score@1", emptySet(), null)
182+
183+
// ably-js parity (liveobject.ts#_notifyPathSubscriptions): one event per full path
184+
assertEquals(setOf("profile.score", "topScore"), events.toSet())
185+
assertEquals(2, events.size)
186+
}
187+
188+
@Test
189+
fun `size and keys exclude entries with unresolvable references`() {
190+
val bridge = graphBridge()
191+
// dangling reference - the target object does not exist in the pool
192+
(bridge.nodes["root"] as FakeMapNode).data["ghost"] = WireObjectData(objectId = "map:deleted@1")
193+
194+
val root = DefaultLiveMapPathObject(bridge, emptyList())
195+
assertEquals(2L, root.size()) // RTLM10d/RTLM14 - ghost not counted
196+
assertEquals(setOf("profile", "flag"), root.keys().toSet())
197+
assertEquals(2L, root.instance()!!.asLiveMap().size()) // RTINS9 - same filtering
198+
}
199+
167200
@Test
168201
fun `instance subscriptions deliver events with the public message`() {
169202
val bridge = graphBridge()

0 commit comments

Comments
 (0)