refactor(transport): put a MessageTransport port in front of Matrix (CHOO-1436) - #363
Draft
amaudruz wants to merge 4 commits into
Draft
refactor(transport): put a MessageTransport port in front of Matrix (CHOO-1436)#363amaudruz wants to merge 4 commits into
amaudruz wants to merge 4 commits into
Conversation
Switch reaches matrix-nio from eight modules, so replacing the homeserver would mean editing all of them at once. Introduce `switch_core.transport`: `MessageTransport` states the contract, `types` holds transport-neutral DTOs and results, and `MatrixTransport` is the one place nio may be imported. The contract is derived from what callers already do rather than invented — its method set is the one the existing test doubles fake. Operations raise `TransportError` instead of returning an error object, so a caller cannot proceed on a failure it forgot to check; `ClientBase.send_message` and `send_media` keep returning None on failure so behaviour is unchanged. ClientBase now holds a transport rather than an AsyncClient, and its Matrix imports drop from sixteen symbols to the seven inbound event types that still appear in hook signatures. `nio_client` remains as a property over the transport's raw client for the call sites in service.py and bridge_core.py that are not yet expressed on the port. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…436) WIP: production code is complete, test fixtures are still being updated. matrix-nio is now imported by exactly one module, `transport/matrix.py`, and no module reaches for a raw client — the `nio_client` escape hatch is gone rather than merely deprecated. A test asserts both, so the seam cannot rot back open unnoticed. ClientBase no longer names an implementation. It holds a `MessageTransport` built by a factory the caller injects, and its credentials are an opaque `session_state` it stores and returns without reading, so what authentication needs is the transport's business alone. Inbound events cross the boundary as DTOs rather than nio classes. The transport converts at the edge, so the 29 `on_*` hooks and their five subclasses take `(RoomRef, Inbound*)`. `InboundMedia` extends `InboundMessage` because a media event is a message that carries a file, which is what the calling code already assumed. Several dicts stop being spelunked: sender name, mimetype, size and thread root are fields now. read_context, media download and the timestamp seek go through the port, so replacing the store later is a matter of another implementation rather than surgery on service.py. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four reads in read_context's helpers still used nio attribute names on objects that are now DTOs. Because they were written defensively — `getattr(event, "server_timestamp", None)` and `hasattr(event, "source")` — the rename produced wrong data rather than an error: - sender names fell back to the raw mxid and attachment metadata came out empty, since `event.source` no longer exists; - every timestamp was None, so `since`/`before` filtering never fired, `oldest_timestamp` was always null and `truncated` was wrong; - `_thread_root_id` never matched, so every message became its own thread root and threading in read_context was gone entirely. Read the DTO fields instead. The defensive spellings are removed with them: an attribute that must exist should fail when it does not. Also adds the send_media wire-format tests that were lost when that serialisation moved behind the port — caption versus filename, and the multi-file group marker — and takes the member display name from the DTO field rather than digging it back out of the content dict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ort (CHOO-1436) The collaboration lifecycle constructs BridgeClient directly rather than through ClientFactory, so it kept passing `device_id` and `access_token` after those were replaced by an opaque `session_state`. All four bridges failed to start; core and the agent bus were unaffected, so the symptom was human channels silently not relaying. Nothing caught it because `BridgeClient.__init__` forwards `**kwargs: Any` to ClientBase — as AdminClient and ResourceManagerClient also do — which hides the constructor from the type checker. A keyword ClientBase no longer accepts type-checks clean and fails when the client starts. The transport factory becomes public so both call sites share one definition instead of each deciding how to build a transport. The regression test walks the AST for any `*Client(...)` call passing a retired credential. Constructing a client correctly, which is what the accompanying construction tests do, says nothing about a call site that constructs it wrongly — those tests pass with this bug reintroduced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Replacing the Matrix homeserver with a Postgres-backed message store means touching every module that imports matrix-nio. There were eight, and two of them —
bridges/agent/protocol/service.pyandbridges/collaboration/bridge_core.py— reached pastClientBaseto the raw nio client at 28 sites between them.This is step 2 of the agreed plan: put a port in front of Matrix so every later step is "write another implementation" rather than surgery on 5,500 lines.
Behaviour is unchanged. That is the point of the step, and the thing to check in review.
What you get
git grep "^from nio\|^import nio" core/switch_core/returns exactly one file:switch_core/transport/matrix.py. No module reaches for a raw client — thenio_clientescape hatch is deleted, not deprecated.A test enforces both by parsing imports (
tests/switch_core/transport/test_no_nio_outside_transport.py), plus a test that the guard can actually detect a violation — a check that always passes is worse than none.The port
transport/types.py— neutral vocabulary.SendResult,UploadResult,DownloadResult,HistoryPage, and the inbound DTOsRoomRef/InboundEvent/InboundMessage/InboundMedia/InboundMembership/InboundCustomEvent. Nothing here names Matrix.transport/port.py—MessageTransport, a runtime-checkableProtocol. Every method corresponds to something a caller already did with a nio client.transport/matrix.py— the implementation. Content dicts,m.relates_to, mxc URIs, pagination tokens, the sync loop, and thetimestamp_to_eventseek all live here.What changed at the callers
ClientBasenames no implementation. It holds aMessageTransportbuilt by a factory the caller injects, so the choice of transport is the factory's business.access_token/device_idare gone;ClientBaseholds asession_statedict it persists and hands back without reading. A client still holding a Matrix access token would still be a Matrix client.on_*hooks and five subclasses take(RoomRef, Inbound*).service.pyandbridge_core.pyare closed. History, single-event fetch, media download, the timestamp seek and all 16com.switch.*sends go through the port.Two design calls worth arguing about
The port raises;
ClientBasestill returnsNone.send_messageused to log-and-return-Noneand callers depend on that, soClientBasecatchesTransportErrorand preserves it exactly. The contract is right and behaviour is unchanged; thetry/exceptpairs go when callers are ready.InboundMediaextendsInboundMessage. mypy forced this and it was the right correction — a media event is a message that carries a file, exactly as nio's ownRoomMessageMediaandRoomMessageTextboth extendRoomMessage. Code that only cares about sender, body or thread now treats them alike, which is what the calling code already assumed.The bug this nearly shipped — worth reading
The mechanical part of this migration was done with a codemod, and it half-converted
read_context. Four reads still used nio attribute names on objects that are now DTOs. Because they were written defensively —getattr(event, "server_timestamp", None),hasattr(event, "source")— nothing raised. It degraded silently:{"mimetype": "", "size": 0}None, sosince/beforefiltering never fired,oldest_timestampwas always null andtruncatedwas wrong_thread_root_idnever matched, so every message became its own thread root — threading inread_contextwas entirely goneThe tell was inside a single function: one line already used the new
timestampfield while another still usedserver_timestamp.This is the failure mode the repo's "fail loud" rule exists to prevent. Those defensive spellings were written when the events really were nio objects, outlived their reason, and turned a rename into wrong data instead of a crash. They are removed along with the bug.
It was caught because the tests were treated as the specification: the fixtures were updated to the new vocabulary, but no assertion was weakened to make a failure go away. Ten tests kept failing and were right to.
Testing
2204 passed, 0 failed. ruff and mypy clean across 196 source files.FakeTransport(tests/switch_core/transport/fake.py) is the shared double replacing every ad-hoc fake nio client.send_mediawire-format cases (caption vs filename, multi-file group marker) that would otherwise have been lost when that serialisation moved behind the port.Not verified
The integration suite against the real Matrix stack has not been run. For a change whose entire claim is behaviour preservation, that is the real safety net and it should be green before this merges.
Deliberately out of scope
matrix_user_idremains as the client's address. Renaming it to a neutralidentityis a repo-wide codemod that belongs with the schema change in step 5.register_userprovisioning and thenext_batchresume cursor stay until the transport is actually replaced.🤖 Generated with Claude Code