diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 194703f19..9a078ef64 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -192,6 +192,11 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO5c5)` The `bufferedObjectOperations` list must be cleared - `(RTO5c9)` The `appliedOnAckSerials` set ([RTO7b](#RTO7b)) must be cleared. A state sync causes the channel's LiveObjects data to be replaced, so after a state sync the `appliedOnAckSerials` no longer accurately describes which operations have been applied to the channel's LiveObjects data - `(RTO5c8)` The [RTO17](#RTO17) sync state must transition to `SYNCED` +- `(RTO27)` When the channel transitions to a state other than `ATTACHED`, the client library must manage the stored objects data as follows (for the effect of these transitions on an in-progress `publishAndApply`, see [RTO20e1](#RTO20e1)): + - `(RTO27a)` When the channel transitions to the `DETACHED` or `FAILED` state, the current state of the objects data can no longer be known, so the client library must: + - `(RTO27a1)` For every object in the internal `ObjectsPool`, clear its internal data, resetting it to the zero value for its type (an empty map, or a counter with value `0`), without emitting any `LiveObjectUpdate` events. The objects themselves remain in the `ObjectsPool`; only their data is cleared + - `(RTO27a2)` The `SyncObjectsPool` must be cleared + - `(RTO27b)` When the channel transitions to the `SUSPENDED` state, the client library must retain the stored objects data unchanged, since the connection may still recover and the retained data remains a valid best-effort local copy - `(RTO6)` Certain object operations may require creating a new object if one does not already exist in the internal `ObjectsPool` for the given `objectId`. This can be done as follows: - `(RTO6a)` If an object with `objectId` exists in `ObjectsPool`, do not create a new object - `(RTO6b)` The expected type of the object can be inferred from the provided `objectId`: diff --git a/uts/README.md b/uts/README.md index 4b8d6bc8c..8cc4d9b9d 100644 --- a/uts/README.md +++ b/uts/README.md @@ -87,6 +87,14 @@ ASSERT error.code == 40160 AWAIT_STATE client.connection.state == ConnectionState.connected ``` +**Identifier naming.** Identifiers that name SDK/spec surface mirror the specification's own names and +casing — `PascalCase` types (`ObjectsPool`), `camelCase` fields and methods (`bufferedObjectOperations`, +`applyOperation`) — so they grep straight back to the spec. Behaviours the spec describes but does not +name get a `camelCase` coinage in the same style (`processAttached`, `processChannelState`), never +`snake_case`. `snake_case` is reserved for test-harness constructs (`setup_synced_channel`, `mock_ws`, +`send_to_client`, `build_*`, `poll_until`). Spec *file* names are `snake_case` (`objects_pool.md`) — +unrelated to symbol casing. Full rules: [docs/writing-test-specs.md](docs/writing-test-specs.md#identifier-naming). + Pseudocode maps to language idioms rather than prescribing exact syntax: - **Absent values**: `== null` means the language-appropriate "absent" value — `undefined` in diff --git a/uts/docs/writing-test-specs.md b/uts/docs/writing-test-specs.md index e60f6fd67..aa403e6b2 100644 --- a/uts/docs/writing-test-specs.md +++ b/uts/docs/writing-test-specs.md @@ -508,6 +508,73 @@ Tests that all REST requests include the `Ably-Agent` header with correct format ## Pseudocode Conventions +### Identifier Naming + +Every identifier in pseudocode is either **spec surface** (an SDK/spec name, cases 1–2 below) or +**test harness** (scaffolding, case 3), and its **casing is the visual signal** of which. Getting this +right keeps the spec surface greppable straight back to the specification and keeps test scaffolding +visually distinct from it. + +**1. Spec surface — mirror the specification's own names exactly.** Anything that names an SDK type, +field, property, or method that a feature specification (`features.md`, `objects-features.md`, …) +already defines must use the **identical name and casing** the spec uses: + +- **Types / concepts → `PascalCase`**: `ObjectsPool`, `SyncObjectsPool`, `InternalLiveMap`, + `InternalLiveCounter`, `Connection`. +- **Fields / properties → `camelCase`**: `bufferedObjectOperations`, `appliedOnAckSerials`, + `clearTimeserial`, `siteTimeserials`, `createOperationIsMerged`. +- **Methods → the spec's member, written as a call**: the spec's `Type#method` form (e.g. + `InternalLiveMap#set`, `RealtimeObject#on`) is written `object.method(...)`; bare method names + (`applyOperation`, `replaceData`) match the spec verbatim. + +Do not rename, abbreviate, or re-case a spec identifier — a reader must be able to grep it out of the +specification unchanged. + +**2. Behaviour the specification describes but does not name.** Some clauses describe an action without +giving it a method name (e.g. RTO4: *"when a channel `ATTACHED` `ProtocolMessage` is received, the +client library must …"* — no method is named). When a white-box test needs to invoke that behaviour +directly, coin a name **in the specification's own style — `camelCase`, never `snake_case`** — and use +it consistently across the suite. Established coinages: `processAttached`, `processObjectSync`, +`processObjectMessage`, `syncState`, `processChannelState`. These are UTS conventions rather than spec +names, but they read as though the spec could have named them. + +**3. Test-harness constructs → `snake_case`.** Everything that is scaffolding rather than SDK/spec +surface — fixtures, mocks, builders, polling/flush helpers — is `snake_case`, so it is instantly +distinguishable from the spec surface: `setup_synced_channel`, `mock_ws`, `send_to_client`, +`build_object_message`, `build_counter_inc`, `install_mock`, `respond_with_success`, `poll_until`, +`process_pending_events`, `encode_uri_component`. + +**Internal (white-box) access** uses the spec's internal name, `camelCase`d: the internal `ObjectsPool` +(RTO3) is reached as `object.objectsPool`; internal fields keep their spec names +(`bufferedObjectOperations`, `appliedOnAckSerials`). How each SDK exposes these is covered in the +derived-test mapping docs. + +**Wire and enum values follow the protocol/spec — not these two buckets.** Identifiers that name +wire-format data or enum members come from `protocol.md` / `features.md` and are matched verbatim; they +are **not** harness, even when `snake_case`: + +- **Wire fields & query parameters** — mostly `camelCase` (`channelSerial`, `connectionId`, + `msgSerial`), with a few `snake_case` (the `request_id` query parameter, RSC7c). +- **Enum members** — the spec's enum casing, typically `SCREAMING_SNAKE_CASE` + (the `ChannelMode.PRESENCE_SUBSCRIBE` enum member / `TR3` flag, also written `presence_subscribe` in + some prose; `SYNCED`; `DETACHED`). Render these per the *Enum values* note in the UTS README. + +**File names are not identifiers.** Spec *files* are `snake_case` `.md` (`objects_pool.md`, +`realtime_object.md`); this is unrelated to symbol casing. The `ObjectsPool` type stays `PascalCase` +even though it is documented in `objects_pool.md`. + +```pseudo +# GOOD — spec surface mirrors the spec; harness is snake_case +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") # harness → snake_case +pool = channel.object.objectsPool # ObjectsPool (RTO3) → camelCase accessor +update = counter.applyOperation(msg, source: CHANNEL) # spec method, verbatim +ASSERT counter.bufferedObjectOperations.length == 0 # spec field, verbatim + +# BAD — spec surface in snake_case: looks like harness, no longer greps back to the spec +pool = channel.object.objects_pool +update = counter.apply_operation(msg) +``` + ### URI Path Component Encoding Use `encode_uri_component()` for any variable path segment or query parameter in URL assertions. This is defined in the UTS README. Always use exact equality (`==`) for path assertions, not `CONTAINS`. diff --git a/uts/objects/unit/internal_live_counter.md b/uts/objects/unit/internal_live_counter.md index 73a36b46d..080db29b4 100644 --- a/uts/objects/unit/internal_live_counter.md +++ b/uts/objects/unit/internal_live_counter.md @@ -11,6 +11,15 @@ Tests the `InternalLiveCounter` CRDT data structure. InternalLiveCounter holds a Tests operate directly on InternalLiveCounter by calling `applyOperation()` and `replaceData()` with constructed messages. No channel or connection infrastructure is needed. +> **SDK portability note — op-path update return value.** Several tests below assert on the +> `LiveCounterUpdate` **returned by `applyOperation(msg, source)`** (`noop`, `update.amount`, +> `tombstone`, `objectMessage`). This models SDKs whose operation application returns the update +> synchronously. An SDK whose operation path returns only a success/failure boolean and instead +> delivers the update via the object's subscription/emit path (e.g. a statically-typed SDK — see +> `objects-features.md` RTTS) may satisfy the same requirement by asserting the **emitted update +> event** and/or the **resulting object state** — these are equivalent observables of the same +> `LiveCounterUpdate`. The sync path (`replaceData()`) returns the update in all SDKs. + ## Shared Helpers See `helpers/standard_test_pool.md` for `build_counter_inc`, `build_counter_create`, `build_object_delete`, `build_object_state`. diff --git a/uts/objects/unit/internal_live_counter_api.md b/uts/objects/unit/internal_live_counter_api.md index 3cf71d535..53f2dc7ac 100644 --- a/uts/objects/unit/internal_live_counter_api.md +++ b/uts/objects/unit/internal_live_counter_api.md @@ -237,6 +237,9 @@ The `null` row applies only where `null` is passable and distinguishable from an argument (e.g. a boxed `Integer` in Java). In SDKs whose signature makes null equivalent to "omitted" (e.g. `increment(amount?: number)` in JavaScript, where a nullish amount takes the default of 1), the row is not applicable — see the pseudocode conventions in `uts/README.md`. +Such SDKs should instead assert the equivalence directly — `increment(null)` succeeds and +increments by the default of 1 — pinning the null-means-omitted contract so a later signature +or coalescing change surfaces as a conscious decision. ### Setup ```pseudo diff --git a/uts/objects/unit/internal_live_map.md b/uts/objects/unit/internal_live_map.md index 348601398..08fcd07d8 100644 --- a/uts/objects/unit/internal_live_map.md +++ b/uts/objects/unit/internal_live_map.md @@ -11,6 +11,16 @@ Tests the `InternalLiveMap` LWW-map CRDT data structure. InternalLiveMap holds a Tests operate directly on InternalLiveMap by calling `applyOperation()` and `replaceData()` with constructed messages. +> **SDK portability note — op-path update return value.** Several tests below assert on the +> `LiveMapUpdate` **returned by `applyOperation(msg, source)`** (`noop`, the per-key `update` diff +> such as `{ "name": "updated"/"removed" }`, `tombstone`, `objectMessage`). This models SDKs whose +> operation application returns the update synchronously. An SDK whose operation path returns only a +> success/failure boolean and instead delivers the update via the object's subscription/emit path +> (e.g. a statically-typed SDK — see `objects-features.md` RTTS) may satisfy the same requirement by +> asserting the **emitted update event** and/or the **resulting map state** — these are equivalent +> observables of the same `LiveMapUpdate`. The sync path (`replaceData()`) returns the update in all +> SDKs. + ## Shared Helpers See `helpers/standard_test_pool.md` for builder functions. diff --git a/uts/objects/unit/path_object.md b/uts/objects/unit/path_object.md index 8a4000a4e..b7c46e003 100644 --- a/uts/objects/unit/path_object.md +++ b/uts/objects/unit/path_object.md @@ -574,6 +574,12 @@ ASSERT result["profile"]["prefs"]["theme"] == "dark" mock_ws.send_to_client(build_object_message("test", [ build_map_set("map:prefs@1000", "back_ref", { objectId: "map:profile@1000" }, "99", "remote") ])) + +# Quiescence barrier: this is an INBOUND server OBJECT message (send_to_client), not a locally +# published operation — so there is no publish-completion / apply-on-ack point to await here. SDKs +# may apply inbound OBJECT messages asynchronously, so wait until the MAP_SET has been applied before +# this POSITIVE read. (The spec's read-after-send conventions otherwise cover only negative assertions.) +poll_until("back_ref" IN root.get("profile").get("prefs").keys(), timeout: 5s) ``` ### Test Steps @@ -626,6 +632,12 @@ ASSERT root.get("score").compact() == 100 mock_ws.send_to_client(build_object_message("test", [ build_map_set("map:prefs@1000", "back_ref", { objectId: "map:profile@1000" }, "99", "remote") ])) + +# Quiescence barrier: this is an INBOUND server OBJECT message (send_to_client), not a locally +# published operation — so there is no publish-completion / apply-on-ack point to await here. SDKs +# may apply inbound OBJECT messages asynchronously, so wait until the MAP_SET has been applied before +# this POSITIVE read. (The spec's read-after-send conventions otherwise cover only negative assertions.) +poll_until("back_ref" IN root.get("profile").get("prefs").keys(), timeout: 5s) ``` ### Test Steps diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md index 411fd9126..60ffaef8c 100644 --- a/uts/objects/unit/realtime_object.md +++ b/uts/objects/unit/realtime_object.md @@ -1,6 +1,6 @@ # RealtimeObject Tests -Spec points: `RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO26` +Spec points: `RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO27` ## Test Type Unit test with mocked WebSocket client @@ -551,6 +551,13 @@ ASSERT events CONTAINS_IN_ORDER ["SYNCING", "SYNCED"] **Spec requirement:** If same listener registered twice, it is invoked twice per event. +> **OPTIONAL for SDKs that de-duplicate listeners by instance.** RTO18d restates the general +> `EventEmitter#on` contract (RTE4): registering the same listener reference twice adds it twice, so it +> fires twice. Some SDKs deliberately de-duplicate listeners keyed by instance (e.g. ably-java's +> `EventEmitter` documents this as an SDK-wide deviation from RTE4). Such an SDK may instead assert +> `call_count == 1` here — or skip this test — and pin its own behaviour; the assertion below is the +> RTE4 reference behaviour. + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") @@ -1538,17 +1545,9 @@ scenarios = [ }, expected_events: ["SYNCING", "SYNCED"] }, - { - name: "re-attach after detach", - trigger: () => { - mock_ws.send_to_client(ProtocolMessage(action: DETACHED, channel: "test")) - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS - )) - mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) - }, - expected_events: ["SYNCING", "SYNCED"] - }, + // No "re-attach after detach" scenario, deliberately: it is redundant with "re-sync on new + // ATTACHED" below (same onAttached -> new-sync path, RTO4c), and not portably expressible — an + // unsolicited server DETACHED transitions to SUSPENDED (not DETACHED) in some SDKs. { name: "re-sync on new ATTACHED", trigger: () => { @@ -1589,3 +1588,58 @@ FOR scenario IN scenarios: ASSERT events == scenario.expected_events ``` + +--- + +## RTO27 - Objects data lifecycle on channel state transitions + +**Test ID**: `objects/unit/RTO27/channel-state-data-lifecycle-0` + +**Spec requirement:** On a channel transition to `DETACHED`/`FAILED`, every object's data is cleared +without emitting update events (RTO27a); on `SUSPENDED`, the data is retained (RTO27b). + +**White-box test.** Like the `internal_live_counter` / `internal_live_map` tests (which call +`applyOperation` directly), this drives the RealtimeObject's internal channel-state handler directly +and inspects the internal `ObjectsPool`. A direct call is required because the behaviour is not +reachable black-box: access after `DETACHED`/`FAILED` throws (RTO25b), and `SUSPENDED` is a +connection-level state that a channel-level mock cannot drive. The abstract symbols map as follows: +- `channel.object.processChannelState(state)` → the SDK's channel-state handler (ably-js + `RealtimeObject.actOnChannelState(state)`, ably-java `DefaultRealtimeObject.handleStateChange(state, false)`). +- `channel.object.objectsPool` → the internal `ObjectsPool` (RTO3) (ably-js `_objectsPool`, ably-java `objectsPool`). + +### RTO27a - DETACHED / FAILED clears data without emitting update events + +```pseudo +FOR state IN [DETACHED, FAILED]: + { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + pool = channel.object.objectsPool # internal ObjectsPool (RTO3) + + # sanity: STANDARD_POOL_OBJECTS has been materialised + ASSERT "name" IN pool["root"].data + ASSERT pool["counter:score@1000"].value == 100 + + # RTO27a1: no update events must be emitted during the clear + updates = [] + pool["root"].subscribe((update) => updates.append(update)) + + channel.object.processChannelState(state) # internal channel-state handler (RTO27) + + # RTO27a1: every object's data is cleared; the objects themselves remain in the pool + ASSERT pool["root"].data == {} + ASSERT "counter:score@1000" IN pool + ASSERT pool["counter:score@1000"].value == 0 + ASSERT updates.length == 0 +``` + +### RTO27b - SUSPENDED retains data + +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +pool = channel.object.objectsPool + +channel.object.processChannelState(SUSPENDED) # RTO27b: no-op for objects data + +# RTO27b: data retained unchanged +ASSERT "name" IN pool["root"].data +ASSERT pool["counter:score@1000"].value == 100 +```