Migrate text payload to canonical posta.text/v1 wire shape #3

Merged
arne merged 1 commit from payload-canonical into main 2026-05-14 18:44:21 +02:00
Owner

Summary

Server PR #28 stopped translating envelopes at the wire boundary and now passes the canonical {kind, body} payload bytes to clients verbatim; migration v8 rewrote stored rows server-side. The server also rejects POST /messages with 400 bad-request when the payload lacks a non-empty kind, so the iOS / Mac clients broke in both directions (inbound rendered as [unsupported message], outbound got 400'd).

This PR brings the client onto the canonical posta.text/v1 wire shape, with a legacy fallback for rows already cached locally from before the server migration.

What changed

  • TextPayload reshape (PostaCore/MessageDTO.swift) — {kind: \"posta.text/v1\", body: String}. Custom Decoder:
    • Prefers the canonical shape ({kind, body}).
    • Falls back to the legacy {\"text\":\"…\"} when no kind is present so old local rows still render.
    • Rejects payloads with a different kind (e.g. posta.link/v1) so they flow through to the [unsupported message] branch.
    • Encoding always emits the canonical shape ({kind: \"posta.text/v1\", body: \"…\"}) so outbound POST /messages passes the server's kind-required check.
  • Call sites (Mac PairedSessionView, iOS ThreadView, SearchResultsView) — read .body; construct outbound via TextPayload(body:).
  • Doc comments in MessageDTO, Message, RawJSON, APIClient now describe the canonical shape.
  • Test fixtures — ~15 hard-coded {\"text\":\"hi\"} test payloads migrated to {\"kind\":\"posta.text/v1\",\"body\":\"hi\"}. New tests cover canonical-encode, wrong-kind rejection, legacy fallback, and the wire body of POST /messages.

Contact-duplication fix

Server PR b620964 now republishes contact-changed as a side-effect of every inbound message. SyncReconciler.applyContactChanged matched the existing Contact row by account.persistentModelID, which SwiftData's #Predicate doesn't compare reliably across the captured-binding boundary — so every emission inserted a fresh duplicate. Aligning the predicate to contact.account.url == accountURL matches the pattern the GET /contacts sync paths already use in both clients.

Test plan

  • swift test passes in PostaCore (102), PostaPersistence (23), PostaSync (35+), PostaTesting (27).
  • iOS + Mac Debug builds succeed.
  • Wiped local SwiftData store on both apps, re-paired, verified messages render and SSE-driven contact-changed no longer duplicates contacts.
  • Send/receive a new message end-to-end against the updated server post-merge.
## Summary Server PR #28 stopped translating envelopes at the wire boundary and now passes the canonical `{kind, body}` payload bytes to clients verbatim; migration v8 rewrote stored rows server-side. The server also rejects `POST /messages` with `400 bad-request` when the payload lacks a non-empty `kind`, so the iOS / Mac clients broke in both directions (inbound rendered as `[unsupported message]`, outbound got 400'd). This PR brings the client onto the canonical `posta.text/v1` wire shape, with a legacy fallback for rows already cached locally from before the server migration. ## What changed - **`TextPayload` reshape** (`PostaCore/MessageDTO.swift`) — `{kind: \"posta.text/v1\", body: String}`. Custom `Decoder`: - Prefers the canonical shape (`{kind, body}`). - Falls back to the legacy `{\"text\":\"…\"}` when no `kind` is present so old local rows still render. - Rejects payloads with a different `kind` (e.g. `posta.link/v1`) so they flow through to the `[unsupported message]` branch. - Encoding always emits the canonical shape (`{kind: \"posta.text/v1\", body: \"…\"}`) so outbound `POST /messages` passes the server's `kind`-required check. - **Call sites** (Mac `PairedSessionView`, iOS `ThreadView`, `SearchResultsView`) — read `.body`; construct outbound via `TextPayload(body:)`. - **Doc comments** in `MessageDTO`, `Message`, `RawJSON`, `APIClient` now describe the canonical shape. - **Test fixtures** — ~15 hard-coded `{\"text\":\"hi\"}` test payloads migrated to `{\"kind\":\"posta.text/v1\",\"body\":\"hi\"}`. New tests cover canonical-encode, wrong-kind rejection, legacy fallback, and the wire body of `POST /messages`. ## Contact-duplication fix Server PR `b620964` now republishes `contact-changed` as a side-effect of every inbound message. `SyncReconciler.applyContactChanged` matched the existing `Contact` row by `account.persistentModelID`, which SwiftData's `#Predicate` doesn't compare reliably across the captured-binding boundary — so every emission inserted a fresh duplicate. Aligning the predicate to `contact.account.url == accountURL` matches the pattern the GET `/contacts` sync paths already use in both clients. ## Test plan - [x] `swift test` passes in PostaCore (102), PostaPersistence (23), PostaSync (35+), PostaTesting (27). - [x] iOS + Mac Debug builds succeed. - [x] Wiped local SwiftData store on both apps, re-paired, verified messages render and SSE-driven `contact-changed` no longer duplicates contacts. - [ ] Send/receive a new message end-to-end against the updated server post-merge.
Server PR #28 stopped translating envelopes at the wire boundary and
now passes the canonical {kind, body} payload bytes through to clients
verbatim; migration v8 rewrote stored rows server-side. The server
also rejects POST /messages with 400 bad-request when the payload
lacks a non-empty `kind`, so the client broke in both directions.

This change:

- Reshapes TextPayload to {kind: "posta.text/v1", body: String} with
  a custom Decoder that prefers the canonical shape, falls back to
  the legacy `{"text":"..."}` for SwiftData rows cached locally from
  before the server migration, and rejects payloads carrying a
  different kind (e.g. posta.link/v1) so they flow through to the
  "[unsupported message]" branch.
- Updates call sites (Mac PairedSessionView, iOS ThreadView,
  SearchResultsView) to use `body` and emit the canonical encoding
  on outbound.
- Refreshes ~15 test fixtures from `{"text":"hi"}` to the canonical
  shape, and adds new coverage for canonical-encode, wrong-kind
  rejection, legacy fallback, and the wire body of POST /messages.

Also fixes a contact duplication bug surfaced by the same server
release. Server PR b620964 now republishes `contact-changed` as a
side-effect of every inbound message; SyncReconciler.applyContactChanged
was matching the existing Contact row by `account.persistentModelID`,
which SwiftData's #Predicate doesn't compare reliably across the
captured-binding boundary, so each emission inserted a fresh
duplicate. Aligning the predicate to `account.url == accountURL`
matches the pattern used by the GET /contacts sync paths in both
clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
arne merged commit a11f440c13 into main 2026-05-14 18:44:21 +02:00
arne deleted branch payload-canonical 2026-05-14 18:44:21 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
posta/ios!3
No description provided.