Skip to content

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

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

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

Conversation

@sacOO7
Copy link
Copy Markdown
Collaborator

@sacOO7 sacOO7 commented May 18, 2026

Summary

Cross-validation against ably-js commit 3deeee8e surfaced multiple gaps between the path-based LiveObjects API spec and the implementation. This PR applies every spec change that doesn't already live in an in-flight PR on this repo.

Scope is deliberately limited to non-PR-delegated items. Three upstream PRs already cover the BatchContext, implicit-attach, and REST API surfaces, and review comments have been posted there with the corresponding spec gaps; this PR doesn't duplicate that work.

Major additions

  • Path event semanticsRTO24cRTO24e: bubbling vs non-bubbling dispatch rules; identity-change non-bubbling event when a parent MAP_SET overwrites a subscribed path.
  • Multi-parent reference graphRTLO3f, RTLO3g, RTO5c10: parentReferences map maintained on every map mutation, getFullPaths returns all distinct root-to-object paths (cycle-safe), pool rebuild after a sync sequence.
  • Tombstone auto-deregistrationRTLO4b8RTLO4b10: instance listeners receive the tombstone update, then are deregistered; path subscriptions unaffected; listener errors caught and logged per ably-js EventEmitter.callListener.
  • Raw LiveMap/LiveCounter rejectionRTLM20e8: validation rejects raw live-object references in MAP_SET; RTLM20e1 marked replaced.
  • PathObject#unsubscribe / Instance#unsubscribe removedRTPO20, RTINS17 marked deleted; deregistration is exclusively via Subscription.unsubscribe() (matches impl).

Validation tightening

  • Channel-mode + state + echo checks at every PathObject/Instance write wrapper (RTPO15eRTPO18e, RTINS12dRTINS15d) and subscribe wrapper (RTPO19i, RTINS16i, RTPO19h).
  • at() path-string type check (RTPO6e).
  • Instance#get conditional key-type check (RTINS5d).
  • subscribeIterator cancellation contract (RTPO21d, RTINS18d).
  • amount validation on increment/decrement wrappers (RTPO17f/18f, RTINS14e/15e).
  • LiveMap value-type entries null rejection (RTLMV4c rewritten).
  • LiveCounter value-type defaulting simplified (RTLCV4a/b1).

Editorial / non-normative additions

  • Compact memoisation broadened (RTPO13b5, RTPO14a2): cyclic OR shared reference dedup.
  • LiveMap value-type immutability scope clarified (RTLMV3d): shallow only.
  • Brand-equivalent return types on LiveMap.create / LiveCounter.create (RTLMV3e, RTLCV3e).
  • Listener ordering and duplicate-subscription independence (RTO24f/g).
  • Subscription idempotency (SUB2b in features.md).
  • RTPO6f path edge cases (empty segment, empty middle segment, trailing backslash, at(p.path()) round-trip).
  • RTLMV4l/m/n: validation-error order, parallelisable server-time fetches, Number type scope (BigInt-equivalent optional).
  • RTO23e: typed channel.object.get<T>() usage hint.

Spec hygiene

  • RTO11/RTO12 deprecation cross-references remapped from umbrella RTLMV3/RTLCV3 to specific sub-clauses (e.g. RTO11f14aRTLMV4e1, RTO12f7RTLCV4g1); post-publish-lookup sub-clauses (RTO11g/h/i, RTO12g/h/i) marked deleted because the wrap-result-as-LiveObject semantics no longer apply.
  • RFC 2119: lowercase shouldMUST on new-in-branch validation clauses (RTLMV4a/b, RTLCV4a).
  • IDL: named type aliases (Primitive, LiveObjectType, Value, ObjectIdReference); internal count/entries fields on value types; unsubscribe lines removed from PathObject/Instance.

Out of scope (delegated to upstream PRs)

These items were intentionally NOT applied locally because in-flight PRs already cover them. Review comments with detailed cross-references to ably-js are posted on each:

Concern Upstream PR Review comment
BatchContext spec + batch() on PathObject/Instance #471 Initial + follow-up
Implicit attach on RealtimeObject#get #472 Comment
REST objects API #476 Comment

Also out of scope:

  • ably-js code changes (raw LiveMap/Counter rejection in validateKeyValue, cheapRandStrrandomString for nonces, depth-validation tightening). Will be raised separately in ably-js.
  • api-docstrings.md rewrites — deferred to a follow-up PR (large editorial task).

Verification

  • (cd build && npm run lint) → ✓ no duplicate IDs across all three spec files.
  • Every applied clause cross-checked against ably-js src/plugins/liveobjects/ at commit 3deeee8e.

Test plan

  • Reviewer spot-checks 3–5 applied clauses against ably-js source.
  • CI lint passes on the PR.
  • Hugo render check (no flat list-item artefacts in deprecation blocks).
  • Confirm the three out-of-scope PRs land separately with their own review comments addressed.

🤖 Generated with Claude Code

Cross-validation against ably-js commit 3deeee8e surfaced multiple gaps
between the path-based API spec and the implementation. This commit
applies all non-PR-delegated spec changes identified in the actionable
review plan.

Major additions:
- Path event semantics (RTO24c-e): bubbling vs non-bubbling event
  dispatch rules
- Multi-parent reference graph (RTLO3f/g, RTO5c10): parentReferences
  maintenance and getFullPaths for multi-path subscription dispatch;
  post-sync rebuild
- Tombstone auto-deregistration (RTLO4b8-b10): instance listeners
  deregistered after the final tombstone update; path subscriptions
  unaffected
- Raw LiveMap/Counter rejection (RTLM20e8): value-type-only consumption
  in MAP_SET; replaces RTLM20e1
- PathObject/Instance unsubscribe(listener) removed (RTPO20, RTINS17):
  deregistration only via Subscription.unsubscribe()

Validation tightening:
- Channel-mode/state/echo checks on PathObject/Instance write methods
  (RTPO15e-18e, RTINS12d-15d) and subscribe wrappers (RTPO19i, RTINS16i,
  RTPO19h)
- at() path string check (RTPO6e)
- Instance#get conditional key-type check (RTINS5d)
- subscribeIterator cancellation (RTPO21d, RTINS18d)
- amount validation on increment/decrement wrappers (RTPO17f/18f,
  RTINS14e/15e)
- LiveMap value-type entries null rejection (RTLMV4c)
- LiveCounter value-type defaulting simplified (RTLCV4a/b1)

Editorial / non-normative:
- Compact memoization broadened (RTPO13b5, RTPO14a2): cyclic OR shared
  reference dedup
- LiveMap value-type immutability shallow-only (RTLMV3d)
- Brand-equivalent return types on LiveMap.create / LiveCounter.create
  (RTLMV3e, RTLCV3e)
- Listener ordering and duplicate-subscription independence (RTO24f/g)
- Subscription idempotency (SUB2b)
- RTPO6f path-parsing edge cases (empty segment, trailing backslash,
  at(p.path()) round-trip)
- RTLMV4l/m/n: validation-error order, parallelisable server-time
  fetches, Number type scope
- RTO23e: typed channel.object.get<T>() usage hint
- Listener-error isolation (RTLO4b10): aligned with ably-js EventEmitter
  callListener pattern

Spec hygiene:
- RTO11/RTO12 deprecation cross-references remapped to specific
  RTLMV3/4 and RTLCV3/4 sub-clauses; post-publish-lookup sub-clauses
  marked deleted
- RFC 2119: lowercase should -> MUST on new-in-branch validation
  clauses (RTLMV4a/b, RTLCV4a, RTLMV4c)
- IDL: named type aliases (Primitive, LiveObjectType, Value,
  ObjectIdReference); internal value-type fields; unsubscribe lines
  removed from PathObject/Instance

Out of scope (delegated to upstream PRs):
- BatchContext spec + batch() on PathObject/Instance (PR #471)
- Implicit attach on RealtimeObject#get (PR #472)
- REST objects API (PR #476)
- ably-js code changes (separate PRs in ably-js repo)

Verified: build/npm-run-lint passes; cross-checked every applied clause
against ably-js src/plugins/liveobjects at commit 3deeee8e.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `(RTPO17b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)). On failure, throws per [RTPO3c2](#RTPO3c2)
- `(RTPO17c)` If the resolved value is a `LiveCounter`, delegates to `LiveCounter#increment` ([RTLC12](#RTLC12)) with the provided `amount`
- `(RTPO17d)` If the resolved value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007
- `(RTPO17e)` Before resolving the path, the wrapper MUST perform the `OBJECT_PUBLISH` channel-mode check ([RTO2](#RTO2)), the channel-state check of [RTLC12c](#RTLC12c), and the `echoMessages` check of [RTLC12d](#RTLC12d)
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'm a bit confused by this (and similar ones here); if we're already delegating to LiveCounter#increment then why would we need to re-perform its checks?

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.

Okay, I checked the ably-js codebase, it seems we have moved those checks from LiveMap/LiveCounter to PathObject level as you can see at ably-js code ->

  1. PathObject#increment
  2. PathObject#decrement
  3. PathObject#set
  4. PathObject#remove
  5. PathObject#size

Seems we need to extract those checks into separate spec point group and then re-use the same at PathObject level. Accordingly we need to remove references from LiveCounter/LiveMap

Copy link
Copy Markdown
Collaborator Author

@sacOO7 sacOO7 May 21, 2026

Choose a reason for hiding this comment

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

Similarly, checks for Instance#increment, Instance#decrement etc have been moved at top level in ably-js code. Though it feels like a duplication of checks since ideally, those should exists at LiveMap/LiveCounter level? But ably-js is our source of truth to perform the operation.

Comment thread specifications/objects-features.md Outdated
- `(RTPO6c)` Returns a new `PathObject` with the same `root` and with the parsed segments appended to the current `path` segments
- `(RTPO6d)` This is a convenience for chaining multiple `PathObject#get` calls. For example, `pathObject.at("a.b.c")` is equivalent to `pathObject.get("a").get("b").get("c")`
- `(RTPO6e)` If `path` is not of type `String`, the library MUST throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003
- `(RTPO6f)` (non-normative) Path parsing edge cases:
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.

Would this not be better off in the UTS?

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.

Removed spec item and other similar items -> cc84fdf

@lawrence-forooghian
Copy link
Copy Markdown
Collaborator

I'm holding off on reviewing this until you've reviewed it yourself but at a glance it seems like it would definitely benefit from you looking through it and deciding what actually needs changing

@lawrence-forooghian
Copy link
Copy Markdown
Collaborator

lawrence-forooghian commented May 18, 2026

The stuff to do with bubbles and parentReferences seems like it might genuinely be necessary because it sounds like core logic (and when I looked at the base PR I didn't fully understand the logic too, see my comments there); I'd suggest focussing on that first

@lawrence-forooghian
Copy link
Copy Markdown
Collaborator

Would you mind taking a look at my comments on #427 to see whether there's any overlap with your changes here please?

@sacOO7
Copy link
Copy Markdown
Collaborator Author

sacOO7 commented May 19, 2026

Would you mind taking a look at my comments on #427 to see whether there's any overlap with your changes here please?

Sure, going through liveobject spec PRs to better understand existing context and avoid relevant duplications

Comment thread specifications/objects-features.md Outdated
- `(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
- `(RTO24b5)` If a listener throws an error, the error must be caught and logged without affecting the dispatch to other subscriptions
- `(RTO24c)` Path events have a boolean `bubbles` attribute (default `true`):
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.

@sacOO7 I've replaced the bubbling logic in ably/ably-js#2223; I'm happy to write the spec for the new logic

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.

Great, seems PR is merged, you can go ahead with the change 👍

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.

Okay, you should create separate PR for bubbles, I will remove the bubbles related spec from here

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.

Removed bubbling specific spec here -> 34bf506, since it's outdated with respect to new implementation at ably/ably-js#2223, you can add it as a part of new PR if needed

- `(RTO11f14c1e)` This clause has been replaced by [RTLMV3](#RTLMV3).
- `(RTO11f14c1f)` This clause has been replaced by [RTLMV3](#RTLMV3).
- `(RTO11f14c2)` This clause has been replaced by [RTLMV3](#RTLMV3).
- `(RTO11f14)` This clause has been replaced by [RTLMV4e](#RTLMV4e).
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 in the interests of keeping this PR as small as possible let's drop this stuff; if the references are genuinely incorrect we can sort it out later

- `(RTPO15b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)). On failure, throws per [RTPO3c2](#RTPO3c2)
- `(RTPO15c)` If the resolved value is a `LiveMap`, delegates to `LiveMap#set` ([RTLM20](#RTLM20)) with the provided `key` and `value`
- `(RTPO15d)` If the resolved value is not a `LiveMap`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007, indicating that the operation is not supported for the resolved object type
- `(RTPO15e)` Before resolving the path, the wrapper MUST perform the `OBJECT_PUBLISH` channel-mode check ([RTO2](#RTO2)), the channel-state check of [RTLM20c](#RTLM20c), and the `echoMessages` check of [RTLM20d](#RTLM20d). If any of these checks fail, the library MUST throw before attempting path resolution
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 even if ably-js technically does these checks before the resolution, it doesn't really matter and it inflates the spec — we can introduce these later if error preference is a real issue. let's drop these

- `(RTPO17c)` If the resolved value is a `LiveCounter`, delegates to `LiveCounter#increment` ([RTLC12](#RTLC12)) with the provided `amount`
- `(RTPO17d)` If the resolved value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007
- `(RTPO17e)` Before resolving the path, the wrapper MUST perform the `OBJECT_PUBLISH` channel-mode check ([RTO2](#RTO2)), the channel-state check of [RTLC12c](#RTLC12c), and the `echoMessages` check of [RTLC12d](#RTLC12d)
- `(RTPO17f)` If `amount` is provided but not a valid finite `Number`, the underlying [RTLC12e1](#RTLC12e1) validation MUST apply and the library MUST throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003. If `amount` is omitted or null, it defaults to 1 per [RTPO17a1](#RTPO17a1)
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 suggest that we remove these validation points, see #427 (comment)

- `(RTINS3)` `Instance#id` property:
- `(RTINS3a)` If the wrapped value is a `LiveObject`, returns the `objectId` of that object
- `(RTINS3b)` If the wrapped value is a primitive, returns undefined/null
- `(RTINS3c)` `id` is the Object ID assigned to the wrapped `LiveObject` at creation time and is immutable: it does not change when the object is tombstoned. For primitives, `id` remains undefined regardless of source
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.

we can lose this

- `(RTINS18a)` If the wrapped value is not a `LiveObject`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007
- `(RTINS18b)` Returns a stream or iterable that yields `InstanceSubscriptionEvent` objects, using the idiomatic construct for the language (e.g. async iterators, channels, flows, or async sequences)
- `(RTINS18c)` Internally wraps `Instance#subscribe` ([RTINS16](#RTINS16)), converting the callback-based subscription into the appropriate streaming or iterable pattern
- `(RTINS18d)` When the returned stream/iterable is closed (e.g. early termination of a `for await … of` loop, or explicit close), the underlying subscription created via [RTINS16](#RTINS16) MUST be deregistered
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 is gone in the upstream

- `(RTLM20e7d)` If the `value` is of type `Number`, set `ObjectMessage.operation.mapSet.value.number` to that value
- `(RTLM20e7e)` If the `value` is of type `Boolean`, set `ObjectMessage.operation.mapSet.value.boolean` to that value
- `(RTLM20e7f)` If the `value` is of type `Binary`, set `ObjectMessage.operation.mapSet.value.bytes` to that value
- `(RTLM20e8)` Validation procedure (supersedes [RTLM20e1](#RTLM20e1)). MUST be performed before any I/O or value-type consumption:
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.

none of these needed

- `(RTLCV3b)` Returns a new `LiveCounterValueType` instance with the internal `count` set to the provided `initialCount` (or 0 if omitted)
- `(RTLCV3c)` No input validation is performed at creation time. Validation is deferred to the consumption procedure ([RTLCV4](#RTLCV4))
- `(RTLCV3d)` The returned `LiveCounterValueType` is immutable and must not be modified after creation
- `(RTLCV3e)` Implementations MAY return the `LiveCounterValueType` instance from `LiveCounter.create` typed as the underlying `LiveCounter` interface (i.e. brand-equivalent) for ergonomic compatibility with mutation methods like [LiveMap#set](#RTLM20). SDKs are free to expose a distinct `LiveCounterValueType` type if their host language type system makes that more idiomatic.
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 we can lose this

- `(RTLMV3b)` Returns a new `LiveMapValueType` instance with the internal `entries` set to the provided `entries` (or undefined if omitted)
- `(RTLMV3c)` No input validation is performed at creation time. Validation is deferred to the consumption procedure ([RTLMV4](#RTLMV4))
- `(RTLMV3d)` The returned `LiveMapValueType` is immutable and must not be modified after creation
- `(RTLMV3d)` The returned `LiveMapValueType` instance is shallowly immutable: its own properties (e.g. `entries`) MUST NOT be reassigned after creation. SDKs MAY shallow-freeze the value type instance. SDKs are NOT required to deep-freeze or deep-copy the user-provided `entries` object. Users SHOULD NOT mutate the `entries` object after passing it to [`LiveMap.create`](#RTLMV3); the behaviour is unspecified if they do.
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 don't think this is necessary

@sacOO7
Copy link
Copy Markdown
Collaborator Author

sacOO7 commented May 21, 2026

Tons of conflicts in current PR, addressed all review comments in #480

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