|
| 1 | +# Private Deviations — Objects UTS specs that cannot (yet) be translated |
| 2 | + |
| 3 | +> **Scope.** This file complements [`deviations.md`](./deviations.md). `deviations.md` records |
| 4 | +> *per-test* deviations inside tests that **were** translated and compile. This file records the |
| 5 | +> opposite: whole UTS spec files from the `objects` module that **could not be translated into the |
| 6 | +> `uts` module at all**, why, and what would unblock them. It is written for a human reviewer / the |
| 7 | +> LiveObjects implementers — not consumed by any tooling. |
| 8 | +
|
| 9 | +--- |
| 10 | + |
| 11 | +## 1. Status of all 15 `objects/unit` specs |
| 12 | + |
| 13 | +| # | UTS spec (`objects/unit/…`) | ably-java test class | Status | Layer it targets | |
| 14 | +|---|---|---|---|---| |
| 15 | +| 1 | `instance.md` | `InstanceTest` | ✅ Translated | Public view (`Instance`) | |
| 16 | +| 2 | `live_counter.md` | `LiveCounterTest` | ⛔ **Blocked** | **Internal CRDT node** | |
| 17 | +| 3 | `live_counter_api.md` | `LiveCounterApiTest` | ✅ Translated | Public view | |
| 18 | +| 4 | `live_map.md` | `LiveMapTest` | ⛔ **Blocked** | **Internal CRDT node** | |
| 19 | +| 5 | `live_map_api.md` | `LiveMapApiTest` | ✅ Translated | Public view | |
| 20 | +| 6 | `live_object_subscribe.md` | `LiveObjectSubscribeTest` | ✅ Translated | Public view | |
| 21 | +| 7 | `object_id.md` | `ObjectIdTest` | ⛔ **Blocked** | **Internal (object-id gen)** | |
| 22 | +| 8 | `objects_pool.md` | `ObjectsPoolTest` | ⛔ **Blocked** | **Internal (`ObjectsPool`)** | |
| 23 | +| 9 | `parent_references.md` | `ParentReferencesTest` | ⛔ **Blocked** | **Internal (parent graph)** | |
| 24 | +| 10 | `path_object.md` | `PathObjectTest` | ✅ Translated | Public view (`PathObject`) | |
| 25 | +| 11 | `path_object_mutations.md` | `PathObjectMutationsTest` | ✅ Translated | Public view | |
| 26 | +| 12 | `path_object_subscribe.md` | `PathObjectSubscribeTest` | ✅ Translated | Public view | |
| 27 | +| 13 | `public_object_message.md` | `PublicObjectMessageTest` | ✅ Translated | Public message layer | |
| 28 | +| 14 | `realtime_object.md` | `RealtimeObjectTest` | ✅ Translated (mixed) | Public `get()` + sync events | |
| 29 | +| 15 | `value_types.md` | `ValueTypesTest` | ✅ Translated (mixed) | Public `create` surface | |
| 30 | + |
| 31 | +**10 translated, 5 blocked.** The 5 blocked specs are the subject of this document. |
| 32 | + |
| 33 | +> Note: the translated specs that depend on `setupSyncedChannel` (most of the public-view tests) |
| 34 | +> compile today but only *run* once the SDK's `OBJECT_SYNC` processing + `RealtimeObject.get()` land. |
| 35 | +> That is the same missing engine described below — see [`deviations.md`](./deviations.md) and the |
| 36 | +> `helpers.kt` header for the per-test runtime caveat. The blocked specs below are a stronger case: |
| 37 | +> they cannot even be *written*. |
| 38 | +
|
| 39 | +--- |
| 40 | + |
| 41 | +## 2. Why these 5 specs target internals |
| 42 | + |
| 43 | +The objects spec is layered into three tiers (see the skill's `objects-mapping.md`): |
| 44 | + |
| 45 | +1. **Creation value types** — the immutable `LiveMap` / `LiveCounter` blueprints you pass *into* `set`. |
| 46 | +2. **Public read/write view** — `PathObject` / `Instance`, what user code navigates and subscribes on. |
| 47 | +3. **Internal CRDT graph** — the live conflict-free replicated nodes, the object pool, object-id |
| 48 | + generation and the parent-reference graph. This is the convergence engine. |
| 49 | + |
| 50 | +The 10 translated specs live in tiers 1–2. The 5 blocked specs **are** tier 3. They have to assert on |
| 51 | +internal state because the behaviour they pin down — last-write-wins arbitration by site-serial, |
| 52 | +idempotent re-application, tombstones, create-op merging, garbage collection, object-id derivation — |
| 53 | +is **not observable through the public API**. You cannot verify "the second of two concurrent ops |
| 54 | +loses by site-serial" with `get()`/`value()`; you have to reach the node's `siteTimeserials` and call |
| 55 | +`applyOperation` directly. So the spec is correct to test internals — that is where the hard logic is. |
| 56 | + |
| 57 | +--- |
| 58 | + |
| 59 | +## 3. The two blockers (in order of severity) |
| 60 | + |
| 61 | +### Blocker A — the internal implementation does not exist yet *(primary)* |
| 62 | + |
| 63 | +`:liveobjects` currently implements the **view** layer only. A symbol search of |
| 64 | +`liveobjects/src/main/kotlin/io/ably/lib/liveobjects` confirms the CRDT engine these specs assert on |
| 65 | +is absent: |
| 66 | + |
| 67 | +| Symbol required by the blocked specs | Found in `:liveobjects`? | |
| 68 | +|---|---| |
| 69 | +| `ObjectsPool` (the live object pool) | ❌ 0 references | |
| 70 | +| `generateObjectId` / object-id derivation (`RTO14`) | ❌ 0 references | |
| 71 | +| `applyOperation(...)` (apply op to a live node) | ❌ 0 references | |
| 72 | +| `replaceData(...)` | ❌ 0 references | |
| 73 | +| `createOperationIsMerged` | ❌ 0 references | |
| 74 | +| parent-reference graph (`parentRef…`) | ❌ 0 references | |
| 75 | +| pool `syncState` | ❌ 0 references | |
| 76 | +| `siteTimeserials` | ⚠️ only on the **wire DTO** (`WireObjectState` / `WireObjectsMapEntry`), not on a live CRDT node | |
| 77 | + |
| 78 | +What *does* exist: `DefaultPathObject`, `DefaultInstance`, the typed `Default*PathObject` / |
| 79 | +`Default*Instance` views, the `value/` creation types, and the `message/` + `serialization/` wire layer. |
| 80 | +There is **no live `InternalLiveMap` / `InternalLiveCounter` node, no `ObjectsPool`, and no |
| 81 | +operation-application engine.** |
| 82 | + |
| 83 | +**Consequence:** even with perfect cross-module visibility there is nothing to instantiate or assert |
| 84 | +against. These tests cannot be authored until the engine is implemented. |
| 85 | + |
| 86 | +### Blocker B — Kotlin `internal` is not visible across the module boundary *(secondary, applies once A is done)* |
| 87 | + |
| 88 | +When the engine *is* implemented it will (by the codebase's convention, and because `:liveobjects` |
| 89 | +uses `explicitApi()`) be declared `internal` — exactly like the existing `Default*` classes |
| 90 | +(`internal class DefaultLiveMap`, `internal class DefaultPathObject`, …). |
| 91 | + |
| 92 | +Kotlin's `internal` is scoped to a **module** = one compilation unit = one Gradle source set's compile |
| 93 | +task. The `:uts` test source set is a *different* module from `:liveobjects`'s `main`. The Kotlin |
| 94 | +compiler enforces `internal` across that boundary **regardless of dependency classpath scope**. So |
| 95 | +`:uts` test code cannot name those declarations at compile time. |
| 96 | + |
| 97 | +This is why the existing helper `buildPublicObjectMessage` (in `helpers.kt`) reaches the internal |
| 98 | +wire/message classes by **reflection** (`Class.forName(...)`), enabled by the current |
| 99 | +`testRuntimeOnly(project(":liveobjects"))` — runtime-only access. Reflection works for a handful of |
| 100 | +constructor/field hops but is the wrong tool for whole-CRDT-state assertions (no type safety, brittle, |
| 101 | +unreadable). |
| 102 | + |
| 103 | +--- |
| 104 | + |
| 105 | +## 4. Per-spec detail |
| 106 | + |
| 107 | +| Spec | What it asserts on | Required internal symbols | Blocked by | |
| 108 | +|---|---|---|---| |
| 109 | +| **2 `live_counter.md`** | internal counter node state after applying ops | `InternalLiveCounter` (`.data`, `.siteTimeserials`, `.createOperationIsMerged`, `applyOperation`, `replaceData`) | A + B | |
| 110 | +| **4 `live_map.md`** | internal map node state after applying ops | `InternalLiveMap` (`.data`, `.siteTimeserials`, `.isTombstone`, `applyOperation`, `replaceData`) | A + B | |
| 111 | +| **7 `object_id.md`** | object-id generation & parsing | `generateObjectId` / object-id type (`RTO14`), `*WithObjectId` derivation | A + B | |
| 112 | +| **8 `objects_pool.md`** | the object pool and its sync lifecycle | `ObjectsPool`, `.syncState`, pool entry add/get/clear | A + B | |
| 113 | +| **9 `parent_references.md`** | the reverse parent-reference graph | parent-reference tracking on the pool/nodes | A + B | |
| 114 | + |
| 115 | +> Specs 2 and 4 have public counterparts (`live_counter_api.md` / `live_map_api.md`, both translated) |
| 116 | +> that cover the *outcome* of these operations through the public API. Specs 7–9 have **no** public |
| 117 | +> counterpart — they are purely internal and have no representation in tiers 1–2. |
| 118 | +
|
| 119 | +--- |
| 120 | + |
| 121 | +## 5. Solution options |
| 122 | + |
| 123 | +The ask was to make all of `liveobjects/src/main/kotlin/io/ably/lib/liveobjects` visible to `:uts`, |
| 124 | +probably by changing `testRuntimeOnly(project(":liveobjects"))` to `testImplementation(...)`. Here is |
| 125 | +the accurate picture, as lead dev. |
| 126 | + |
| 127 | +### 5.1 Why the `testRuntimeOnly` → `testImplementation` swap alone is *not* sufficient |
| 128 | + |
| 129 | +```kotlin |
| 130 | +// uts/build.gradle.kts — current |
| 131 | +testRuntimeOnly(project(":liveobjects")) // runtime classpath only → reflection-only access |
| 132 | + |
| 133 | +// proposed |
| 134 | +testImplementation(project(":liveobjects")) // adds COMPILE classpath too |
| 135 | +``` |
| 136 | + |
| 137 | +`testImplementation` puts `:liveobjects` on the **compile** classpath, which lets `:uts` reference its |
| 138 | +**public** API directly (and lets the reflection helpers drop some `Class.forName`). But it does **not** |
| 139 | +grant access to `internal` declarations: Kotlin enforces `internal` at the *module* boundary at |
| 140 | +compile time, and a dependency-scope change does not cross that boundary. The CRDT engine will be |
| 141 | +`internal`, so the swap by itself does not unblock these tests. It is **necessary but not sufficient.** |
| 142 | + |
| 143 | +### 5.2 The Gradle/Kotlin configs that *can* expose internals |
| 144 | + |
| 145 | +There is exactly **one** primitive that grants one Kotlin compilation access to another's `internal` |
| 146 | +declarations: the compiler flag **`-Xfriend-paths`**. Everything below is either that flag directly, or |
| 147 | +a higher-level wrapper around it. |
| 148 | + |
| 149 | +**(a) `associateWith()` — the *supported* form, but intra-project only.** |
| 150 | +The Kotlin Gradle plugin exposes friend-paths through the `associateWith` API on compilations: |
| 151 | + |
| 152 | +```kotlin |
| 153 | +kotlin.target.compilations.getByName("test") |
| 154 | + .associateWith(kotlin.target.compilations.getByName("main")) |
| 155 | +``` |
| 156 | + |
| 157 | +This is how a module's own `test` source set sees its `main` internals (the plugin wires it up |
| 158 | +automatically), and how you'd give a *custom* source set (e.g. `integrationTest`) the same access. It is |
| 159 | +stable and IDE-aware — **but only between compilations of the same Gradle project.** There is no |
| 160 | +supported way to `associateWith` a compilation in a *different* project (`:uts` test ↔ `:liveobjects` |
| 161 | +main). Source: KTIJ-7662, KT associated-compilations docs. |
| 162 | + |
| 163 | +**(b) Raw `-Xfriend-paths` across projects — works, but unstable/unsupported.** |
| 164 | +You can manually point `:uts`'s test-compile task at `:liveobjects`'s `main` output: |
| 165 | +`-Xfriend-paths=…/liveobjects/build/classes/kotlin/main`. Per the Kotlin team this flag has *"no syntax, |
| 166 | +no IDE support, and no guarantees of stability — a compiler implementation detail, not a language |
| 167 | +feature."* It also hard-couples `:uts` to `:liveobjects`'s internal compile output path. **Not |
| 168 | +recommended** for production build config. |
| 169 | + |
| 170 | +**(c) The future fix (not available yet): `shared internal` (KEEP-0451).** |
| 171 | +The Kotlin team is *not* stabilizing `-Xfriend-paths`; instead KEEP-0451 proposes a first-class |
| 172 | +`shared internal` visibility modifier — declarations visible to designated dependent modules but not |
| 173 | +the general public. When it ships this is the clean answer, but it is a proposal today, not usable. |
| 174 | + |
| 175 | +### 5.3 Cleanest technical approach — write the internal-graph tests in `:liveobjects`'s own test source set |
| 176 | + |
| 177 | +> This is the lowest-ceremony option and what the SDK already does for its own internals, but it places |
| 178 | +> the tests **outside `uts/unit`**. If keeping them under `uts/unit` is required, prefer §5.4(a) instead. |
| 179 | +
|
| 180 | +`:liveobjects` **already has** a unit-test source set and task: |
| 181 | + |
| 182 | +```kotlin |
| 183 | +// liveobjects/build.gradle.kts (existing) |
| 184 | +tasks.register<Test>("runLiveObjectsUnitTests") { |
| 185 | + filter { includeTestsMatching("io.ably.lib.liveobjects.unit.*") } |
| 186 | +} |
| 187 | +``` |
| 188 | + |
| 189 | +Tests placed under `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/…` see **all** of `main`'s |
| 190 | +`internal` declarations automatically (the plugin sets the friend-path for a module's own tests). This |
| 191 | +is the standard, supported way to test internal Kotlin code. |
| 192 | + |
| 193 | +**Plan once Blocker A is resolved (engine implemented):** |
| 194 | +1. Author specs 2, 4, 7, 8, 9 in `:liveobjects`'s own test source set, e.g. package |
| 195 | + `io.ably.lib.liveobjects.unit.uts`, mirroring the `uts` conventions (one `@Test` per spec case, a |
| 196 | + `/** @UTS objects/unit/… */` KDoc tag, the `deviations.md` discipline). |
| 197 | +2. Specs 7–9 (`object_id`, `objects_pool`, `parent_references`) are pure logic with no network/sync — |
| 198 | + they will **run immediately** there, no mock-WebSocket harness needed. |
| 199 | +3. Specs 2 and 4 need object state applied to a node; reuse / port the relevant `helpers.kt` builders. |
| 200 | +4. Keep `:uts`'s `testRuntimeOnly(project(":liveobjects"))` as-is (reflection helpers stay valid), or |
| 201 | + optionally promote to `testImplementation` purely for compile-time access to the **public** API. |
| 202 | + |
| 203 | +**Trade-off:** these five tests then live outside the `uts` module the skill normally targets. That is |
| 204 | +acceptable and correct — they are internal-implementation tests, and the SDK already groups its own |
| 205 | +internal tests under `:liveobjects`. The `@UTS` id convention keeps them traceable to the spec. |
| 206 | + |
| 207 | +### 5.4 Keeping the tests under `uts/unit` — what actually works |
| 208 | + |
| 209 | +This is the stated preference, so it gets its own analysis. To assert on `:liveobjects` internals from |
| 210 | +test code that physically lives in `:uts`, the realistic options are: |
| 211 | + |
| 212 | +**(a) `java-test-fixtures` bridge — the recommended way to honour the `uts/unit` preference.** |
| 213 | +Apply the `java-test-fixtures` plugin to `:liveobjects` and put a thin **inspection/bridge** layer in |
| 214 | +`liveobjects/src/testFixtures/kotlin`. Fixture code *belongs to the module*, so it can touch |
| 215 | +`:liveobjects` internals; it then re-exposes them as a small **public** API (e.g. |
| 216 | +`fun applyAndSnapshot(...): PublicCounterSnapshot`). `:uts` consumes it with: |
| 217 | + |
| 218 | +```kotlin |
| 219 | +// uts/build.gradle.kts |
| 220 | +testImplementation(testFixtures(project(":liveobjects"))) |
| 221 | +``` |
| 222 | + |
| 223 | +The **assertions stay in `uts/unit`** (calling the fixture's public API) — your preference is satisfied — |
| 224 | +while no raw internal is leaked onto `:uts`'s classpath. Caveat: for the *fixture itself* to see Kotlin |
| 225 | +`internal`, its compilation must be associated with `main` (Android exposes |
| 226 | +`android.experimental.enableTestFixturesKotlinSupport`; for plain JVM Kotlin verify the testFixtures→main |
| 227 | +`associateWith` is wired — it reduces back to §5.2(a), which is supported because it is intra-project). |
| 228 | +Cost: you design and maintain the bridge surface. |
| 229 | + |
| 230 | +**(b) Reflection from `:uts` (status quo, no build change).** |
| 231 | +Current `testRuntimeOnly(project(":liveobjects"))` already lets `:uts` reach internals by reflection at |
| 232 | +runtime — this is what `buildPublicObjectMessage` does. Keeps tests in `uts/unit` with zero build |
| 233 | +changes, but: stringly-typed, no compile-time safety, brittle to refactors, and verbose for whole-CRDT |
| 234 | +assertions. Fine for a couple of accessors; poor for five spec files of state assertions. |
| 235 | + |
| 236 | +> **On `@VisibleForTesting`:** it does **not** change visibility. It is a documentation/lint hint that |
| 237 | +> records what the visibility *would* be if not for tests; the actual access is still governed by the |
| 238 | +> `public`/`internal` modifier. So "mark it `@VisibleForTesting`" only helps if you *also* make the |
| 239 | +> member `public` (e.g. `@VisibleForTesting(otherwise = PRIVATE) public fun …`). It is not, by itself, a |
| 240 | +> cross-module visibility mechanism. |
| 241 | +
|
| 242 | +--- |
| 243 | + |
| 244 | +## 6. The realistic options |
| 245 | + |
| 246 | +Only **three** approaches are real candidates for our situation. (The mechanisms in §5.2 — |
| 247 | +`associateWith`, raw `-Xfriend-paths`, `shared internal` — and the bare dependency-scope swap in §5.1 are |
| 248 | +*not* viable on their own; see the note below.) |
| 249 | + |
| 250 | +| Option | Keeps tests in `uts/unit`? | Compile-safe? | Trade-off | |
| 251 | +|---|---|---|---| |
| 252 | +| **A. `java-test-fixtures` bridge** (§5.4a) | ✅ yes | ✅ yes | Design a small public snapshot surface in `:liveobjects`'s `testFixtures`. **Best fit for the preference.** | |
| 253 | +| **B. Tests in `:liveobjects/src/test`** (§5.3) | ❌ no — live in `:liveobjects` | ✅ yes | Least effort; internals visible by design. The SDK already tests its own internals this way. | |
| 254 | +| **C. Reflection from `:uts`** (§5.4b) | ✅ yes | ❌ no | No build change, but stringly-typed and brittle — fine for a few hops, poor for 5 spec files. | |
| 255 | + |
| 256 | +**Not viable on their own:** the bare `testRuntimeOnly` → `testImplementation` swap (exposes only the |
| 257 | +*public* API, not `internal`); `associateWith` (supported but *intra-project* — cannot bridge `:uts` ↔ |
| 258 | +`:liveobjects`); raw cross-project `-Xfriend-paths` (unstable/unsupported); `shared internal` / |
| 259 | +KEEP-0451 (future proposal, not available yet). |
| 260 | + |
| 261 | +## 7. Recommendation & sequencing |
| 262 | + |
| 263 | +1. **Now:** nothing to translate for specs 2, 4, 7, 8, 9 — the internal engine they test is unbuilt |
| 264 | + (Blocker A). Leave them blocked; this document is the record. |
| 265 | +2. **When the LiveObjects CRDT engine (`ObjectsPool`, internal live nodes + `applyOperation`, |
| 266 | + object-id generation, parent references) is implemented**, pick the visibility approach: |
| 267 | + - **To honour the `uts/unit` preference (recommended):** the **`java-test-fixtures` bridge** (§5.4a) — |
| 268 | + assertions stay in `uts/unit`, a small fixture in `:liveobjects` exposes the needed internal state |
| 269 | + as a public snapshot. Compile-safe and supported. |
| 270 | + - **If colocation isn't required:** author them in **`:liveobjects/src/test`** (§5.3) — least |
| 271 | + ceremony, internals visible by design (the SDK already tests its own internals this way). |
| 272 | +3. **Avoid** the bare `testImplementation` swap *as the internal-access mechanism* (it only exposes the |
| 273 | + public API) and the manual cross-project `-Xfriend-paths` hack (unsupported, fragile). |
| 274 | + |
| 275 | +--- |
| 276 | + |
| 277 | +## 8. References |
| 278 | + |
| 279 | +- Kotlin associated compilations / `associateWith` (intra-project internal access): |
| 280 | + KTIJ-7662, Kotlin Multiplatform "Configure compilations" docs. |
| 281 | +- `-Xfriend-paths` is an unstable compiler detail; future `shared internal` modifier: KEEP-0451 |
| 282 | + ("Shared Internals" proposal). |
| 283 | +- `@VisibleForTesting` is a lint/documentation hint and does not change visibility. |
| 284 | +- Gradle `java-test-fixtures`: test-fixtures code has access to the module's internal API and is |
| 285 | + consumed via `testImplementation(testFixtures(project(":…")))`; Kotlin support may require |
| 286 | + associating the testFixtures compilation with `main`. |
0 commit comments