Skip to content

Align path-based LiveObjects API spec with ably-js implementation#480

Draft
sacOO7 wants to merge 13 commits into
AIT-30/liveobjects-path-based-api-specfrom
fix/liveobjects-path-spec-based-on-ably-js-v2
Draft

Align path-based LiveObjects API spec with ably-js implementation#480
sacOO7 wants to merge 13 commits into
AIT-30/liveobjects-path-based-api-specfrom
fix/liveobjects-path-spec-based-on-ably-js-v2

Conversation

@sacOO7
Copy link
Copy Markdown
Collaborator

@sacOO7 sacOO7 commented May 21, 2026

Summary

LiveObjects spec changes covering path-based subscriptions, identity-based subscription lifecycle behavior, and deregistration APIs:

• Multi-parent reference tracking (RTLO3f, RTLO3g, RTO5c10, RTO24b1)

  • Each LiveObject now maintains a parentReferences map keyed by parent LiveMap.
  • The map is updated during MAP_SET / MAP_REMOVE / MAP_CLEAR operations and when a LiveMap is tombstoned.
  • A new internal getFullPaths helper traverses the parent graph upward to the root with cycle protection, returning all distinct paths currently referencing the object (or an empty set if the object is orphaned).
  • PathObjectSubscriptionRegister (RTO24b1) now uses getFullPaths to determine dispatch targets, allowing a single object update to fan out across every path referencing that object.
  • After sync completion, the pool rebuilds all parentReferences from the finalized LiveMap state.

• Tombstone-driven auto-deregistration (RTLO4b8, RTLO4b9, RTLO4b10)

  • LiveObject#subscribe listeners receiving a tombstone update (OBJECT_DELETE or sync tombstone) are invoked once and then automatically deregistered.
  • Subsequent updates to that object no longer notify those listeners.
  • Path-based subscriptions (RTPO19) are intentionally excluded from this behavior because they track paths rather than object identity; a later MAP_SET assigning a new object to the same path continues delivering updates.
  • Listener exceptions are caught and logged, and do not interrupt dispatch to remaining listeners.

Subscription as the sole deregistration mechanism (SUB2b, RTLO4c, RTPO20, RTINS17)

  • Removes imperative unsubscribe(listener) APIs from LiveObject, PathObject, and Instance (corresponding clauses deleted).
  • Callers now deregister exclusively through the Subscription returned by subscribe.
  • Subscription#unsubscribe is explicitly documented as idempotent; repeated calls are treated as no-ops.

• IDL updates

  • Removes deprecated unsubscribe declarations.
  • Adds a Primitive type alias.
  • Exposes the internal count and entries backing fields on LiveCounterValueType and LiveMapValueType.

…i-spec' into fix/liveobjects-path-spec-based-on-ably-js-v2

# Conflicts:
#	specifications/objects-features.md
@github-actions github-actions Bot temporarily deployed to staging/pull/480 May 21, 2026 19:22 Inactive
lawrence-forooghian added a commit that referenced this pull request May 22, 2026
`Subscription` (returned by `subscribe`) is now the sole
deregistration mechanism, matching the ably-js public API.

RTLO4c is retained as a "This clause has been deleted" stub since
it existed on main; RTPO20 and RTINS17 are removed outright as
they were introduced earlier in this PR branch. The corresponding
`unsubscribe` declarations are also removed from the IDL.

Lifted from Sachin's spec-alignment PR [1].

[1] #480

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread specifications/objects-features.md Outdated
- `(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
- `(RTPO20)` This clause has been deleted
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.

incorporated into #427: 9858d2f

Comment thread specifications/objects-features.md Outdated
- `(RTINS17)` `Instance#unsubscribe` function:
- `(RTINS17a)` Accepts a `listener` argument and deregisters it from receiving further events using `LiveObject#unsubscribe` ([RTLO4c](#RTLO4c))
- `(RTINS17b)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status
- `(RTINS17)` This clause has been deleted
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.

incorporated into #427: 9858d2f

- `(RTLO4b8c)` Subsequent updates on this `LiveObject` MUST NOT invoke the deregistered listeners
- `(RTLO4b9)` Path-based subscriptions ([RTPO19](#RTPO19)) are NOT affected by [RTLO4b8](#RTLO4b8): they remain registered after the underlying object is tombstoned, because path subscriptions follow a path, not an object identity. A subsequent `MAP_SET` on the parent that creates a new object at the same path will deliver further events to the existing path subscription
- `(RTLO4b10)` If a registered listener throws when invoked with a `LiveObjectUpdate` per [RTLO4b4c2](#RTLO4b4c2), the error MUST be caught and logged. The error MUST NOT affect the dispatch to other listeners registered on the same `LiveObject`, nor abort the iteration over the listener list
- `(RTLO4c)` This clause has been deleted
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.

incorporated into #427: 9858d2f

…point,

removed unnecessary `RTLO4b8`, `RTLO4b9` and `RTLO4b10`
Describes types for RealtimeObject.\
Types and their properties/methods are public and exposed to users by default. An `internal` label may be used to indicate that a type or its property/method must not be exposed to users and is intended for internal SDK use only.

// Primitive is an alias used throughout the IDL
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.

Primitive does not appear anywhere else in this PR, what purpose is this serving?

Comment thread specifications/objects-features.md Outdated
- `(RTLO3e1)` Set to undefined/null when the `LiveObject` is initialized
- `(RTLO3f)` protected `parentReferences` - a mapping from each parent `LiveMap` to the set of keys at which that `LiveMap` currently references this `LiveObject`. The map MUST be maintained as map operations are applied so that path-based subscribers ([RTO24](#RTO24)) can determine every path the object currently occupies in the LiveObjects tree
- `(RTLO3f1)` Set to an empty map when the `LiveObject` is initialized
- `(RTLO3f2)` When a `MAP_SET` operation adds an entry whose `ObjectData.objectId` references another `LiveObject` in the local `ObjectsPool`, the referenced object's `parentReferences` MUST be updated to include the entry `(parent LiveMap, key)`
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.

these things that should be done when an operation is applied belong in the respective parts of the spec, not here

lawrence-forooghian added a commit that referenced this pull request May 22, 2026
Adds SUB2b clarifying that repeated calls to `Subscription#unsubscribe`
are a no-op, matching the ably-js implementation across all three
subscription factories (LiveObject EventEmitter.off, the
PathObjectSubscriptionRegister Map.delete, and Instance which delegates
to LiveObject).

Lifted from Sachin's spec-alignment PR [1].

[1] #480

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `(SUB1)` A `Subscription` represents a registration for receiving events from a subscribe operation
- `(SUB2)` The `Subscription` object has the following method:
- `(SUB2a)` `unsubscribe` - deregisters the listener that was registered by the corresponding `subscribe` call. Once `unsubscribe` is called, the listener must not be called for any subsequent events
- `(SUB2b)` Calling `unsubscribe` more than once is a no-op
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.

added to #427: e1adbc0

lawrence-forooghian added a commit that referenced this pull request May 22, 2026
The internal `count` (RTLCV2a) and `entries` (RTLMV2a) properties
were already specified in prose but missing from the IDL block.
Add them, matching the private `_count` and `_entries` fields on
ably-js's `LiveCounterValueType` and `LiveMapValueType`.

Lifted from Sachin's spec-alignment PR [1].

[1] #480

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
update: Dict<String, 'updated' | 'removed'> // RTLM18b

class LiveCounterValueType: // RTLCV*
count: Number // RTLCV2a, internal
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.

added to #427: 1e80b42

static create(Number initialCount?) -> LiveCounterValueType // RTLCV3

class LiveMapValueType: // RTLMV*
entries: Dict<String, (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounterValueType | LiveMapValueType)>? // RTLMV2a, internal
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.

added to #427: 1e80b42

Comment thread specifications/objects-features.md Outdated
- `(RTLO3d1)` Set to `false` when the `LiveObject` is initialized
- `(RTLO3e)` protected `tombstonedAt` (optional) Time - a timestamp indicating when this object was tombstoned. This property is nullable, and specification points that manipulate this value maintain the invariant that it is non-null if and only if `isTombstone` is `true`
- `(RTLO3e1)` Set to undefined/null when the `LiveObject` is initialized
- `(RTLO3f)` protected `parentReferences` - a mapping from each parent `LiveMap` to the set of keys at which that `LiveMap` currently references this `LiveObject`. The map MUST be maintained as map operations are applied so that path-based subscribers ([RTO24](#RTO24)) can determine every path the object currently occupies in the LiveObjects tree
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.

Please can you add to the IDL

Copy link
Copy Markdown
Collaborator

@lawrence-forooghian lawrence-forooghian May 22, 2026

Choose a reason for hiding this comment

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

I've added a placeholder for parentReferences in #427 (8ce7584) and added it to the IDL. Note that the parent references are now stored as object ID strings, not direct references; I've added a spec point that explains why (commit message expands on this) but provides flexibility for implementing like ably-js. When I pull in getFullPaths() spec I'll update accordingly to reflect this

lawrence-forooghian added a commit that referenced this pull request May 22, 2026
Stubs out the new `parentReferences` internal property on
`LiveObject`, needed by `getFullPaths` (RTLO4f). The detailed
maintenance rules (across `MAP_SET`, `MAP_REMOVE`, `MAP_CLEAR`,
`LiveMap` tombstoning, and post-sync rebuild) are deferred to a
follow-up by Sachin; the in-progress draft is at [1].

ably-js stores `parentReferences` as a map keyed by a direct
`LiveMap` reference; the placeholder instead keys by `objectId`,
for consistency with how the rest of the LiveObjects spec models
inter-object references (forward references in `LiveMap` entries
are already objectIds resolved via the `ObjectsPool` on demand).

This is also load-bearing for languages without automatic cycle
collection. The protocol allows cyclic `LiveMap` graphs (e.g.
`A.x = B`, `B.y = A`), and `getFullPaths` is being specified to
handle them; under ARC in Swift, direct parent references in such
a cycle would form an unbreakable retain cycle on the two
`LiveMap`s. Keying by `objectId` lets the `ObjectsPool` remain the
single owner and sidesteps the issue.

Implementations remain explicitly permitted to store a direct
`LiveMap` reference if more idiomatic in their language -- e.g.
to avoid an `ObjectsPool` lookup at each `getFullPaths` traversal
step -- as ably-js does today, provided they handle the cycle
concern.

[1] #480

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lawrence-forooghian added a commit that referenced this pull request May 22, 2026
Lifts the `getFullPaths` definition verbatim from commit ecf85df of
Sachin's spec-alignment PR [1].

The only departure from the source is renumbering: Sachin places
`getFullPaths` under `RTLO3 LiveObject properties` as `RTLO3g`;
this commit places it under `RTLO4 LiveObject methods` as `RTLO4f`
(with sub-clauses `RTLO4f1`-`RTLO4f7`) since `getFullPaths` is a
function, not a property. Cross-references in RTO24b1 and RTLO3f
are updated to match.

Lawrence has not reviewed the lifted content yet; the imported
clauses retain Sachin's capitalised RFC 2119 keywords and the
NetworkX references, both of which may be tightened in follow-up
commits.

[1] #480

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread specifications/objects-features.md Outdated
- `(RTLO3f4)` When a `MAP_REMOVE` operation tombstones an entry that previously referenced an object, the previously-referenced object's `parentReferences` entry for `(parent LiveMap, key)` MUST be removed
- `(RTLO3f5)` When a `MAP_CLEAR` operation removes an entry that previously referenced an object, the previously-referenced object's `parentReferences` entry for `(parent LiveMap, key)` MUST be removed
- `(RTLO3f6)` When a `LiveMap` is tombstoned per [RTLO4e](#RTLO4e), all references it holds to other `LiveObjects` MUST be removed from those objects' `parentReferences` maps
- `(RTLO3g)` internal `getFullPaths` function - returns the list of distinct paths from the root `LiveMap` (objectId `root`) to this `LiveObject`, computed by traversing `parentReferences` upward. Each returned path is an ordered sequence of keys from `root` to this `LiveObject`.
Copy link
Copy Markdown
Collaborator

@lawrence-forooghian lawrence-forooghian May 22, 2026

Choose a reason for hiding this comment

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

have copied the getFullPaths definition to #427 (moved to sit under RTLO4 which is the more appropriate location): ec61d11

Comment thread specifications/objects-features.md Outdated
- `(RTLO3f4)` When a `MAP_REMOVE` operation tombstones an entry that previously referenced an object, the previously-referenced object's `parentReferences` entry for `(parent LiveMap, key)` MUST be removed
- `(RTLO3f5)` When a `MAP_CLEAR` operation removes an entry that previously referenced an object, the previously-referenced object's `parentReferences` entry for `(parent LiveMap, key)` MUST be removed
- `(RTLO3f6)` When a `LiveMap` is tombstoned per [RTLO4e](#RTLO4e), all references it holds to other `LiveObjects` MUST be removed from those objects' `parentReferences` maps
- `(RTLO3f2)` internal `addParentReference(parent, key)` method - records that the `LiveMap` `parent` references this `LiveObject` at `key`
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.

just a note that methods belong under RTLO4, not RTLO3

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 also in the IDL

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed 1d96f53

Comment thread specifications/objects-features.md Outdated
- `(RTLO4e3b)` This clause has been replaced by [RTLO6b](#RTLO6b)
- `(RTLO4e3b1)` This clause has been replaced by [RTLO6b1](#RTLO6b1)
- `(RTLO4e5)` If this `LiveObject` is a `LiveMap`, before [RTLO4e4](#RTLO4e4) is applied, for each `ObjectsMapEntry` in this `LiveMap`'s current `data`:
- `(RTLO4e5a)` If `ObjectsMapEntry.data` is not an `ObjectIdObjectData`, no action is required for that entry
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.

ObjectIdObjectData is not a concept that exists in the spec; this will need to be worded in some alternative fashion

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Comment thread specifications/objects-features.md Outdated
- `(RTLM24d)` Set the private `clearTimeserial` to the provided `serial`
- `(RTLM24e)` For each `ObjectsMapEntry` in the internal `data`:
- `(RTLM24e1)` If `ObjectsMapEntry.timeserial` is null or omitted, or the `serial` is lexicographically greater than `ObjectsMapEntry.timeserial`:
- `(RTLM24e1c)` Before [RTLM24e1a](#RTLM24e1a) is applied, the parent reference recorded on the `LiveObject` referenced by this entry (if any) MUST be removed:
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.

Do we need a "before"? the order doesn't seem to matter? everything here is atomic

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, we also need to rephrase it

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed 68e88d6

sacOO7 and others added 2 commits May 22, 2026 23:55
…i-spec' into fix/liveobjects-path-spec-based-on-ably-js-v2

I've deliberately left one conflict in to resolve, need to understand
the parentReferences stuff first.
@github-actions github-actions Bot temporarily deployed to staging/pull/480 May 22, 2026 19:05 Inactive
@github-actions github-actions Bot temporarily deployed to staging/pull/480 May 24, 2026 16:16 Inactive
lawrence-forooghian added a commit that referenced this pull request May 24, 2026
PR #480 [1] proposed specifying that ably-js deregisters all
LiveObject#subscribe listeners on tombstone. Adopt that proposal
with refined wording and a new LiveObjectUpdate.tombstone field
that makes the trigger condition explicit. Also add the related
ably-js refactor (commit 1d98cc3 [2]) that has tombstone() return
the cleared LiveObjectUpdate rather than dispatching it inline.

[1] #480
[2] ably/ably-js@1d98cc3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread specifications/objects-features.md Outdated
- `(RTLO4b4c3)` Otherwise:
- `(RTLO4b4c3a)` The registered listener of each subscription created via `LiveObject#subscribe` ([RTLO4b](#RTLO4b)) on this `LiveObject` is called with the `LiveObjectUpdate`
- `(RTLO4b4c3b)` Perform path-based subscription dispatch as described in [RTO24b](#RTO24b), passing this `LiveObject` and the `LiveObjectUpdate`
- `(RTLO4b4c3c)` When a `LiveObjectUpdate` is emitted as a result of a tombstone - i.e. an `OBJECT_DELETE` operation or a sync state with `tombstone: true`, after all listeners have been invoked from [RTLO4b4c3a](#RTLO4b4c3a) and [RTLO4b4c3b](#RTLO4b4c3b), the library MUST deregister all listeners on this `LiveObject`. Path-based subscriptions ([RTPO19](#RTPO19)) are NOT affected by `tombstone` update.
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.

Thanks for noticing this — I've tried to tighten it up a bit in 2fa9a5e, so will remove from this PR

- `(RTLO3d1)` Set to `false` when the `LiveObject` is initialized
- `(RTLO3e)` protected `tombstonedAt` (optional) Time - a timestamp indicating when this object was tombstoned. This property is nullable, and specification points that manipulate this value maintain the invariant that it is non-null if and only if `isTombstone` is `true`
- `(RTLO3e1)` Set to undefined/null when the `LiveObject` is initialized
<<<<<<< HEAD
Copy link
Copy Markdown
Collaborator

@lawrence-forooghian lawrence-forooghian May 24, 2026

Choose a reason for hiding this comment

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

I've pulled all the parentReferences stuff into the main PR in 860e479

lawrence-forooghian added a commit that referenced this pull request May 24, 2026
Imports the parentReferences bookkeeping spec from PR #480 [1] onto
this integration branch, resolving the committed conflict marker at
RTLO3f and the duplicate clause IDs introduced by the import.

Imported from #480 verbatim:

- RTO5c10: post-sync rebuild of every parentReferences map.
- addParentReference and removeParentReference internal methods,
  with set-merge / set-remove / empty-set-delete semantics.
- Tombstone-time children walk for LiveMap, stripping parent
  references from each referenced child before the data is cleared.
- MAP_SET, MAP_REMOVE and MAP_CLEAR parent-reference maintenance
  (RTLM7a3, RTLM7i, RTLM8a3, RTLM24e1c).
- IDL declarations for the two new internal methods.

The Primitive type alias added in #480 was deliberately not
imported, as it is unrelated to the parentReferences work.

Conflicts reconciled:

- The committed <<<<<<< / >>>>>>> block at RTLO3f. Kept the
  objectId-keyed Dict<String, Set<String>> description from this
  branch (consistent with #480's own IDL line and its set-style
  manipulation contracts; the alternative half mandated a specific
  in-memory representation that ably-js does not match literally).
  The "set to an empty map on initialisation" clause from #480 was
  moved to RTLO3f2; the prior RTLO3f2 TODO is deleted, since the
  imported maintenance rules now resolve it. RTO5c10a's
  back-reference was updated to point at the new RTLO3f2.
- Duplicate clause IDs introduced by #480 were renamed per the
  "rename the later addition" convention in CONTRIBUTING.md:
    - addParentReference: RTLO4f -> RTLO4g
    - removeParentReference: RTLO4g -> RTLO4h
    - tombstone children walk: RTLO4e5* -> RTLO4e9*
  All cross-references to the renamed clauses were updated
  accordingly. The pre-existing RTLO4f (getFullPaths) and
  RTLO4e5-e8 (Compute LiveObjectUpdate through Return) are
  untouched.

Linter passes. Still needs human review.

[1] #480

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lawrence-forooghian added a commit that referenced this pull request May 24, 2026
Follow-up to 860e479. The clauses pulled in from PR #480 use the
uppercase RFC 2119 convention (MUST etc.); lowercase them for
consistency with the prose style preferred on this branch.

Touches the 10 occurrences of MUST in RTO5c10, RTLO4g1-g2,
RTLO4h1-h3, RTLM7a3, RTLM7i, RTLM8a3 and RTLM24e1c. The
pre-existing uppercase keywords in RTLO4f1-f7 (getFullPaths) are
intentionally left alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lawrence-forooghian added a commit that referenced this pull request May 24, 2026
The IDL entries imported from PR #480 declared these two methods
without argument types. Annotate them as (LiveMap parent, String
key), matching the conventional style used for multi-arg methods
elsewhere in the IDL and the parent/key descriptions in the
RTLO4g/RTLO4h prose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lawrence-forooghian
Copy link
Copy Markdown
Collaborator

@sacOO7 I believe that I've now moved all of the changes from here to #427 (with some modifications as mentioned above)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants