Skip to content

[AIT-30] LiveObjects Path-based API spec#427

Open
VeskeR wants to merge 3 commits into
integration/liveobjects-path-based-apifrom
AIT-30/liveobjects-path-based-api-spec
Open

[AIT-30] LiveObjects Path-based API spec#427
VeskeR wants to merge 3 commits into
integration/liveobjects-path-based-apifrom
AIT-30/liveobjects-path-based-api-spec

Conversation

@VeskeR
Copy link
Copy Markdown
Contributor

@VeskeR VeskeR commented Feb 24, 2026

Note: This PR is based on #470; please review that one first.

Resolves AIT-30.

@VeskeR VeskeR force-pushed the AIT-30/liveobjects-path-based-api-spec branch from 13dee45 to 1e518c6 Compare February 24, 2026 13:46
@VeskeR VeskeR force-pushed the AIT-30/liveobjects-path-based-api-spec branch from 1fa3eeb to 46261f4 Compare February 24, 2026 15:52
@VeskeR VeskeR force-pushed the AIT-313/protocol-v6-state-message branch 3 times, most recently from 49f0364 to 47a9d51 Compare February 27, 2026 15:52
Base automatically changed from AIT-313/protocol-v6-state-message to main February 27, 2026 15:53
@ttypic ttypic force-pushed the AIT-30/liveobjects-path-based-api-spec branch from 46261f4 to 3608895 Compare March 9, 2026 10:54
@github-actions github-actions Bot temporarily deployed to staging/pull/427 March 9, 2026 10:55 Inactive
@lawrence-forooghian lawrence-forooghian force-pushed the AIT-30/liveobjects-path-based-api-spec branch from 3608895 to b4ad764 Compare May 12, 2026 18:41
@github-actions github-actions Bot temporarily deployed to staging/pull/427 May 12, 2026 18:41 Inactive
@lawrence-forooghian lawrence-forooghian added live-objects Related to LiveObjects functionality. labels May 12, 2026
@lawrence-forooghian lawrence-forooghian changed the base branch from main to rename-channel-objects-to-object May 12, 2026 19:37
@lawrence-forooghian lawrence-forooghian force-pushed the rename-channel-objects-to-object branch 2 times, most recently from 7738c92 to fa2a54e Compare May 12, 2026 19:45
- `(RTO22b)` `CHANNEL` - an operation received over a Realtime channel
- `(RTO24)` Internal `PathObjectSubscriptionRegister` - manages path-based subscriptions for `PathObject#subscribe` ([RTPO19](#RTPO19))
- `(RTO24a)` The `RealtimeObject` instance maintains a single `PathObjectSubscriptionRegister` that manages all path-based subscriptions for the channel
- `(RTO24b)` When a `LiveObject` in the `ObjectsPool` emits a `LiveObjectUpdate` (per [RTLO4b4](#RTLO4b4)), the `PathObjectSubscriptionRegister` must determine which subscriptions should be notified:
Copy link
Copy Markdown
Collaborator

@lawrence-forooghian lawrence-forooghian May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW RTLO4b still describes subscribe() as public

- `(RTO24b1)` Determine the paths in the LiveObjects tree at which the updated `LiveObject` is located
- `(RTO24b2)` For each registered subscription, check whether the event path starts with (or equals) the subscription's path
- `(RTO24b3)` If the event path matches, apply depth filtering: the event is dispatched to the subscription if the number of path segments from the subscription path to the event path plus 1 does not exceed the subscription's `depth` option (or if `depth` is undefined). Formally, the event is dispatched if `eventPath.length - subscriptionPath.length + 1 <= depth`
- `(RTO24b4)` Create a `PathObjectSubscriptionEvent` with a `PathObject` pointing to the event path and the `ObjectMessage` that caused the change, and call the subscription's listener
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with a PathObject pointing to the event path

So am I right in thinking that neither PathObject nor (from what I can see in RTPO8 PathObject#instance) Instance have a concept of pointer identity i.e. there may be multiple PathObject or Instance objects for a single path / LiveObject respectively?


interface PathObjectSubscriptionEvent: // RTPO19d
object: PathObject // RTPO19d1
message: ObjectMessage? // RTPO19d2
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this literally the raw ObjectMessage that's received over the wire or is there a mapping to some public type?

interface LiveObjectSubscription: // RTLO4b5
unsubscribe() // RTLO4b5a

interface LiveObjectUpdate: // RTLO4b4
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this whole type now internal?

- `(RTO24b1)` Determine the paths in the LiveObjects tree at which the updated `LiveObject` is located
- `(RTO24b2)` For each registered subscription, check whether the event path starts with (or equals) the subscription's path
- `(RTO24b3)` If the event path matches, apply depth filtering: the event is dispatched to the subscription if the number of path segments from the subscription path to the event path plus 1 does not exceed the subscription's `depth` option (or if `depth` is undefined). Formally, the event is dispatched if `eventPath.length - subscriptionPath.length + 1 <= depth`
- `(RTO24b4)` Create a `PathObjectSubscriptionEvent` with a `PathObject` pointing to the event path and the `ObjectMessage` that caused the change, and call the subscription's listener
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and the ObjectMessage that caused the change

I'm wondering if it's always going to be clear what was the "ObjectMessage that caused the change". Given that PathObjectSubscriptionEvent#message is optional, it suggests there's something non-obvious here. It feels like we need to also add the ObjectMessage to the LiveObjectUpdate that's emitted in RTLO4b4 and make sure that all the spec points that describe emitting such an update describe what value to use for the ObjectMessage.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar point re Instance#subscribe (RTINS16d1)

- `(RTO24)` Internal `PathObjectSubscriptionRegister` - manages path-based subscriptions for `PathObject#subscribe` ([RTPO19](#RTPO19))
- `(RTO24a)` The `RealtimeObject` instance maintains a single `PathObjectSubscriptionRegister` that manages all path-based subscriptions for the channel
- `(RTO24b)` When a `LiveObject` in the `ObjectsPool` emits a `LiveObjectUpdate` (per [RTLO4b4](#RTLO4b4)), the `PathObjectSubscriptionRegister` must determine which subscriptions should be notified:
- `(RTO24b1)` Determine the paths in the LiveObjects tree at which the updated `LiveObject` is located
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This spec point feels like it's doing some heavy lifting; the procedure for doing this seems non-obvious

- `(RTPO19d1)` `object` - a `PathObject` pointing to the path where the change occurred
- `(RTPO19d2)` `message` `ObjectMessage` (optional) - the `ObjectMessage` that caused the change
- `(RTPO19e)` The subscription is path-based: it follows the path, not a specific object. If the object at the path changes identity (e.g. via a `MAP_SET` operation replacing it), the subscription continues to deliver events for the new object at that path
- `(RTPO19f)` Events at child paths bubble up to the subscription, subject to depth filtering. For example, a subscription at path `a.b` receives events for changes at `a.b`, `a.b.c`, `a.b.c.d`, etc., depending on the configured depth. The dispatch rules are described in [RTO24b](#RTO24b)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this spec point saying something that RTO24b2 and RTO24b3 don't?

- `(RTPO19d)` The listener receives a `PathObjectSubscriptionEvent` object with:
- `(RTPO19d1)` `object` - a `PathObject` pointing to the path where the change occurred
- `(RTPO19d2)` `message` `ObjectMessage` (optional) - the `ObjectMessage` that caused the change
- `(RTPO19e)` The subscription is path-based: it follows the path, not a specific object. If the object at the path changes identity (e.g. via a `MAP_SET` operation replacing it), the subscription continues to deliver events for the new object at that path
Copy link
Copy Markdown
Collaborator

@lawrence-forooghian lawrence-forooghian May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like RTPO19e-f could lean more heavily on RTO24b; it's not clear if they're normative or not. Probably should say something like "add a subscription to the PathObjectSubscriptionRegister"

- `(RTPO19a2)` `options` `PathObjectSubscriptionOptions` (optional) - subscription options
- `(RTPO19b)` `PathObjectSubscriptionOptions` has the following properties:
- `(RTPO19b1)` `depth` `Number` (optional) - controls how many levels deep in the subtree changes trigger the listener:
- `(RTPO19b1a)` If undefined (default), the subscription receives events for changes at any depth below the subscribed path
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are RTPO19b1a-c intended to be non-normative (i.e. just a description of the intention of the API, not a specification of how to implement)? It seems like the actual logic comes from RTO24b3.

- `(RTPO3c2)` For write operations (`set`, `remove`, `increment`, `decrement`), the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92005, indicating that the path could not be resolved
- `(RTPO4)` `PathObject#path` function:
- `(RTPO4a)` Returns a dot-delimited string representation of the stored path segments
- `(RTPO4b)` Any dot characters (`.`) occurring within individual path segments must be escaped with a backslash (`\`) in the returned string. For example, a path with segments `["a", "b.c", "d"]` is represented as `a.b\.c.d`
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is a backslash a valid character in a path segment?

- `(RTPO20)` `PathObject#unsubscribe` function:
- `(RTPO20a)` Accepts a `listener` argument and deregisters it from receiving further events for this `PathObject`'s path
- `(RTPO20b)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status
- `(RTPO21)` The client library should provide a method that allows consuming subscription events as a stream or iterable, rather than via a callback. A suggested name for this method is `subscribeIterator`:
Copy link
Copy Markdown
Collaborator

@lawrence-forooghian lawrence-forooghian May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how well spec points like this will scale, because they really express a general principle that SDKs should consider exposing event streams in other platform-idiomatic fashions; it's not really expressing anything LiveObjects-specific. What do you think?

- `(RTINS8)` `Instance#values` function:
- `(RTINS8a)` Behaves identically to `Instance#entries` ([RTINS6](#RTINS6)) except that it yields only the `Instance` values
- `(RTINS9)` `Instance#size` function:
- `(RTINS9a)` If the wrapped value is a `LiveMap`, returns the number of non-tombstoned entries (equivalent to `LiveMap#size`, see [RTLM10](#RTLM10))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we just say it delegates to that then?

- `(RTINS6a)` If the wrapped value is a `LiveMap`, returns an iterator yielding `[key, Instance]` pairs, where each `Instance` wraps the corresponding entry value from `LiveMap#entries` ([RTLM11](#RTLM11))
- `(RTINS6b)` If the wrapped value is not a `LiveMap`, returns an empty iterator
- `(RTINS7)` `Instance#keys` function:
- `(RTINS7a)` Behaves identically to `Instance#entries` ([RTINS6](#RTINS6)) except that it yields only the keys
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my suggestion for #keys and #values would be to keep their semantics in the LiveMap spec points and then just delegate to those same as suggested for RTINS9a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto for the corresponding PathObject points — resolve (with error on failure) and then delegate

- `(RTINS16b)` If the wrapped value is not a `LiveObject` (i.e. it is a primitive), the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007, indicating that subscribe is not supported for primitive values
- `(RTINS16c)` Subscribes to data updates on the underlying `LiveObject` using `LiveObject#subscribe` ([RTLO4b](#RTLO4b))
- `(RTINS16d)` The listener receives an `InstanceSubscriptionEvent` object with:
- `(RTINS16d1)` `object` - the `Instance` representing the updated object
Copy link
Copy Markdown
Collaborator

@lawrence-forooghian lawrence-forooghian May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels clearer:

Suggested change
- `(RTINS16d1)` `object` - the `Instance` representing the updated object
- `(RTINS16d1)` `object` - the `Instance` on which `#subscribe` was called

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or is there a reason to create a new one each time? (if so we should make it clearer still)

- `(RTINS9a)` If the wrapped value is a `LiveMap`, returns the number of non-tombstoned entries (equivalent to `LiveMap#size`, see [RTLM10](#RTLM10))
- `(RTINS9b)` If the wrapped value is not a `LiveMap`, returns undefined/null
- `(RTINS10)` `Instance#compact` function:
- `(RTINS10a)` Behaves identically to `PathObject#compact` ([RTPO13](#RTPO13)), but operates on the wrapped value directly instead of resolving a path
Copy link
Copy Markdown
Collaborator

@lawrence-forooghian lawrence-forooghian May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: comprehensibility-wise, I like the general approach that the spec takes of treating PathObject and Instance as two different views of some underlying data, so it would be nice if the #compact and #compactJson methods could also follow this i.e. be a generic procedure for any LiveObject or primitive instead of having Instance refer to PathObject.

- `(RTPO8)` `PathObject#instance` function:
- `(RTPO8a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3))
- `(RTPO8b)` If the resolved value is a `LiveObject` (i.e. a `LiveMap` or `LiveCounter`), returns a new `Instance` ([RTINS1](#RTINS1)) wrapping that `LiveObject`
- `(RTPO8c)` If the resolved value is a primitive, returns undefined/null
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I thought we have primitive instances in JS?

- `(RTPO8d)` If path resolution fails, returns undefined/null per [RTPO3c1](#RTPO3c1)
- `(RTPO9)` `PathObject#entries` function:
- `(RTPO9a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3))
- `(RTPO9b)` If the resolved value is a `LiveMap`, returns an iterator yielding `[key, PathObject]` pairs, where each `PathObject` is created as if by calling `PathObject#get` with the corresponding key on this `PathObject`
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that RTPO9b and RTP9c can be replaced by a single RTPO9b that just says we delegate to LiveMap#keys and then return the [key, PathObject] pairs

- `(RTPO11a)` Behaves identically to `PathObject#entries` ([RTPO9](#RTPO9)) except that it yields only the `PathObject` values
- `(RTPO12)` `PathObject#size` function:
- `(RTPO12a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3))
- `(RTPO12b)` If the resolved value is a `LiveMap`, returns the number of non-tombstoned entries (equivalent to `LiveMap#size`, see [RTLM10](#RTLM10))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as mentioned elsewhere I don't think we need to mention "equivalent to" or mention the semantics, just delegate to that method

- `(RTPO13)` `PathObject#compact` function:
- `(RTPO13a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3))
- `(RTPO13b)` If the resolved value is a `LiveMap`, returns a recursively compacted representation as a plain key-value object:
- `(RTPO13b1)` Each entry in the `LiveMap` is included in the result. Tombstoned entries are excluded
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again this could maybe delegate to LiveMap.entries() instead of repeating logic like skip tombstones?

- `(RTPO12a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3))
- `(RTPO12b)` If the resolved value is a `LiveMap`, returns the number of non-tombstoned entries (equivalent to `LiveMap#size`, see [RTLM10](#RTLM10))
- `(RTPO12c)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns undefined/null
- `(RTPO13)` `PathObject#compact` function:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this have the same throwing rules as LiveMap and LiveCounter accessors e.g. RTLC5b?

- `(RTO11f1)` If `entries` is null or not of type `Dict`, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003, indicating that `entries` must be a `Dict`. Note that `entries` is an optional argument, and if omitted, this error must not be thrown
- `(RTO11f2)` If any of the keys provided in `entries` are not of type `String`, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003, indicating that keys must be `String`
- `(RTO11f3)` If any of the values provided in `entries` are not of an expected type, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40013, indicating that such data type is unsupported
- `(RTO23d)` Returns a `PathObject` ([RTPO1](#RTPO1)) wrapping the `LiveMap` with id `root` from the internal `ObjectsPool`. The `PathObject` is created with an empty path, rooted at the `root` `LiveMap`
Copy link
Copy Markdown
Collaborator

@lawrence-forooghian lawrence-forooghian May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a bit confusing because first part of the sentence suggests a PathObject wraps the map at its path, but rather it has a RTPO2b root property


### LiveCounterValueType

A `LiveCounterValueType` is an immutable blueprint for creating a new `LiveCounter` object. It stores the desired initial count value and is consumed when passed to a mutation method such as `LiveMap#set` ([RTLM20](#RTLM20)) or as an entry value in `LiveMap.create` ([RTLMV3](#RTLMV3)).
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an immutable blueprint

I kind of prefer LiveCounterBlueprint — it indicates that it's not a LiveCounter but something else, whereas ValueType doesn't communicate very much (it's… what, a LiveCounter that is a value type?)

Copy link
Copy Markdown
Collaborator

@lawrence-forooghian lawrence-forooghian May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or LiveCounterTemplate ("template" is a bit overloaded in some languages, but "blueprint" is perhaps a bit flowery)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked Claude to have a think

My top picks if I had to shortlist five:

  1. LiveCounterBlueprint — clear metaphor, distinctive, distinct from anything
    in software vocab.
  2. LiveCounterSeed — short, evocative, fits the "grows into" semantic.
  3. LiveCounterRecipe — pleasant metaphor, captures "follow these instructions
    to produce".
  4. LiveCounterIntent — most honest about the actual semantic; mirrors RTLCV1.
  5. LiveCounterTemplate — most conventional; baggage but familiar.

Of these, Blueprint and Seed feel the most distinctive and unambiguous to me.
Seed has the bonus of being short.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't it what we call "CreateParams" in other SDKs (Chat AFAIK)? LiveCounterCreateParams is recognizable by everyone I guess.

- `(RTPO5)` `PathObject#get` function:
- `(RTPO5a)` Expects the following arguments:
- `(RTPO5a1)` `key` `String` - the key to navigate to
- `(RTPO5b)` If `key` is not of type `String`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003, indicating that the key must be a `String`
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I've expressed it before but I think that these sorts of spec points are starting to pollute the spec a bit (there's quite a lot of them); it feels like there could, somewhere, be a single generic spec point saying that if a language without compiler-guaranteed argument types wishes to perform validation of the input types then if the validation fails it should throw an error with statusCode 400 and code 40003.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, we do seem to have a distinction between 40003 ("invalid parameter value") and 40013 ("Invalid message data or encoding") but to me it feels like they both represent the same class of error when thrown client-side (user passed an invalid argument to an SDK method)

- `(RTLMV4c)` If any of the values in the internal `entries` are not of an expected type, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40013, indicating that such data type is unsupported
- `(RTLMV4d)` Build entries for the `MapCreate` object. For each key-value pair in the internal `entries` (if present), create an `ObjectsMapEntry` for the value:
- `(RTLMV4d1)` If the value is of type `LiveCounterValueType`, consume it per [RTLCV4](#RTLCV4) to generate a `COUNTER_CREATE` `ObjectMessage`. Collect the generated `ObjectMessage` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the `ObjectMessage`
- `(RTLMV4d2)` If the value is of type `LiveMapValueType`, recursively consume it per [RTLMV4](#RTLMV4) to generate `ObjectMessages`. Collect all generated `ObjectMessages` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the outermost `MAP_CREATE` `ObjectMessage`
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

- `(RTPO8d)` If path resolution fails, returns undefined/null per [RTPO3c1](#RTPO3c1)
- `(RTPO9)` `PathObject#entries` function:
- `(RTPO9a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3))
- `(RTPO9b)` If the resolved value is a `LiveMap`, returns an iterator yielding `[key, PathObject]` pairs, where each `PathObject` is created as if by calling `PathObject#get` with the corresponding key on this `PathObject`
Copy link
Copy Markdown
Collaborator

@lawrence-forooghian lawrence-forooghian May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we use the term "iterator" elsewhere in any of our other specs. I think that JS is free to do as it likes (although I don't really understand why it chose to go with iterators for the path-based API types) but that for understandability in the spec we should just use arrays. (ditto in the IDL)

- `(RTO22b)` `CHANNEL` - an operation received over a Realtime channel
- `(RTO24)` Internal `PathObjectSubscriptionRegister` - manages path-based subscriptions for `PathObject#subscribe` ([RTPO19](#RTPO19))
- `(RTO24a)` The `RealtimeObject` instance maintains a single `PathObjectSubscriptionRegister` that manages all path-based subscriptions for the channel
- `(RTO24b)` When a `LiveObject` in the `ObjectsPool` emits a `LiveObjectUpdate` (per [RTLO4b4](#RTLO4b4)), the `PathObjectSubscriptionRegister` must determine which subscriptions should be notified:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Presumably also we want to exclude updates with noOp set to true? would be good if we could keep the "don't do anything on noOp events" logic in a single place instead of having to repeat it here though

lawrence-forooghian added a commit to ably/ably-js that referenced this pull request May 19, 2026
After noticing that the path-based API spec PR [1] didn't include
map-entry events nor the bubbling-exclusion mechanism that's implemented
in ably-js, I wanted to get a better understanding of this exclusion
mechanism and why it exists.

From what I can tell, it exists to make sure that a subscription never
emits two events for the same LiveMapUpdate. One particular case in
which this would otherwise happen is the scenario when, after the user
subscribes for path ["map"] which contains a map, this map then emits an
update for some key "myKey", which without this exclusion mechanism
would emit two events to that subscription: one for "map" and one for
"map.key".

If that _is_ the only reason that this mechanism exists, then it seems
like it might conceptually simpler to gather all the paths that we
consider a given LiveObjectUpdate to have touched and then for each
subscription look at these paths and pick zero or one of these paths to
emit an event for, with a shortest-match tiebreaker rule. This makes
the "one event per subscription" rule clearer, and I _think_ is simpler
to specify.

So I'm proposing this change as a way of generating discussion. Perhaps
I've misunderstood the intent of `bubbles`, or perhaps there are ways in
which it might be extended in the future that this approach doesn't
cover, or perhaps what I'm proposing is not in other people's opinions
conceptually simpler. Thoughts, please.

[1] ably/specification#427

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lawrence-forooghian added a commit to ably/ably-js that referenced this pull request May 19, 2026
After noticing that the path-based API spec PR [1] didn't include
map-entry events nor the bubbling-exclusion mechanism that's implemented
in ably-js, I wanted to get a better understanding of this exclusion
mechanism and why it exists.

From what I can tell, it exists to make sure that a subscription never
emits two events for the same LiveMapUpdate. One particular case in
which this would otherwise happen is the scenario when, after the user
subscribes for path ["map"] which contains a map, this map then emits an
update for some key "myKey", which without this exclusion mechanism
would emit two events to that subscription: one for "map" and one for
"map.key".

If that _is_ the only reason that this mechanism exists, then it seems
like it might be conceptually simpler to gather all the paths that we
consider a given LiveObjectUpdate to have touched and then for each
subscription look at these paths and pick zero or one of these paths to
emit an event for, with a shortest-match tiebreaker rule. This makes the
"one event per subscription" rule clearer, and I _think_ is simpler to
specify.

So I'm proposing this change as a way of generating discussion. Perhaps
I've misunderstood the intent of `bubbles` (which, given that the tests
still pass, would suggest a test gap), or perhaps this proposed approach
doesn't allow for additional dispatch mechanisms that we might need in
the future, or perhaps others don't find what I'm proposing conceptually
simpler. Thoughts, please.

[1] ably/specification#427

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lawrence-forooghian added a commit to ably/ably-js that referenced this pull request May 19, 2026
After noticing that the path-based API spec PR [1] didn't include
map-entry events nor the bubbling-exclusion mechanism that's implemented
in ably-js, I wanted to get a better understanding of this exclusion
mechanism and why it exists.

From what I can tell, it exists to make sure that a subscription never
emits two events for the same LiveMapUpdate. One particular case in
which this would otherwise happen is the scenario when, after the user
subscribes for path ["map"] which contains a map, this map then emits an
update for some key "myKey", which without this exclusion mechanism
would emit two events to that subscription: one for "map" and one for
"map.key".

If that _is_ the only reason that this mechanism exists, then it seems
like it might be conceptually simpler to gather all the paths that we
consider a given LiveObjectUpdate to have touched and then for each
subscription look at these paths and pick zero or one of these paths to
emit an event for, with a shortest-match tiebreaker rule. This makes the
"one event per subscription" rule clearer, and I _think_ is simpler to
specify.

So I'm proposing this change as a way of generating discussion. Perhaps
I've misunderstood the intent of `bubbles` (which, given that the tests
still pass, would suggest a test gap), or perhaps this proposed approach
doesn't allow for additional dispatch mechanisms that we might need in
the future, or perhaps others don't find what I'm proposing conceptually
simpler. Alternatively, perhaps `bubbles` just needs better
documentation that explains its motivation. Thoughts, please.

[1] ably/specification#427

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

live-objects Related to LiveObjects functionality.

Development

Successfully merging this pull request may close these issues.

5 participants