fix(messages): decode the metadata route's real {links} shape - #106
Merged
Merged
Conversation
POST /api/messages/{id}/metadata answers `{ "links": [...] }` at the top
level from both of its exits (route.ts:86 for "no links detected", :105
for the fetched items), and every entry is a full LinkMetadataItem built
by fetchMultipleLinkMetadata — the same producer behind /api/link-metadata
and the same shape the feed row already carries. `serialize()` normalizes
values only; it never restructures.
refreshMessageMetadata expected a `metadata` wrapper around flat
{url,title,description,image} previews, so `response.metadata` was always
nil and the call returned [] against the live API, silently. `links` is now
decoded as required rather than optional: degrading a missing key to an
empty array is exactly how the mismatch stayed invisible.
With the route's entries already in the feed row's shape, the
MessageLinkPreview -> LinkMetadataItem conversion added by #103 has nothing
left to convert. It and MessageLinkPreview (no other consumer) are deleted
along with their tests, and ComposeView passes the decoded links straight
to AppDataStore.applyLinkMetadata.
The fixtures are rewritten from the route and metadata-fetcher sources, not
from the Swift type: success, failed-entry (no `metadata`, no `fetchedAt`),
the empty short-circuit, Instagram's unmodelled caption/derivedList extras,
and the old wrapper shape, which must now throw instead of decoding to [].
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1
This was referenced Sep 16, 2026
Closed
Adron
changed the base branch from
feat/apply-link-metadata-to-posted-row
to
main
September 17, 2026 08:59
One conflict, in APIClient.swift, resolved by taking this branch's version. Main carries the "No caller yet, deliberately" note #90 added to refreshMessageMetadata; that became stale the moment #94 wired the call from ComposeView, and this branch replaces it with a comment describing the route's actual response shape. Keeping both would have restated a claim the code now contradicts. Verified after resolving: the stale note is gone, the decode reads top-level `links` as required, and MessageLinkPreview has no remaining references. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1
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.
Summary
Closes #104. Stacked on #103 → #94: this branch is based on
feat/apply-link-metadata-to-posted-row(#103), which is itself based onfeat/refresh-link-metadata-after-publish(#94). Both must merge first, in that order; the diff below is only this commit, on top of that stack.APIClient.refreshMessageMetadatadecoded{"metadata":{"links":[…]}}into a flatMessageLinkPreview.app/api/messages/[id]/metadata/route.tsreturnsserialize({ links: … })— top-levellinks, no wrapper — from both exits (:86when no link is detected,:105with the fetched items), andserialize()normalizes values without ever restructuring. Soresponse.metadatawas always nil and the call returned[]against the live API, silently, which is what made #96/#103 inert.Each entry is built by
fetchMultipleLinkMetadata(lib/messages/metadata-fetcher.ts) — the same producer behindGET /api/link-metadata, which iOS already decodes intoLinkMetadataItem, and the same shape persisted ontomessage.linkMetadataand rendered by the feed row. So the fix is to decode{ links: [LinkMetadataItem] }directly.linksis decoded as required, not optional-with-?? []. A missing key now throws instead of degrading to an empty array, because that degradation is precisely how the mismatch survived a year of green tests.What's included
refreshMessageMetadatareturns[LinkMetadataItem]and decodes the route's real shape. The path, method,postCamelencoder, empty body and fire-and-forgettry?at the call site are unchanged.ComposeView.refreshLinkMetadata(for:)hands the decoded links straight toAppDataStore.applyLinkMetadata(_:toMessageId:)(feat(messages): apply refreshed link metadata to posted row #103), which is unchanged and now receives real entries end to end: a success entry carriesmetadataand renders; afetchStatus:"failed"entry has nometadata, andFeedView:883only drawsLinkPreviewBlockfor a non-nilmetadata, so it renders nothing — mirroring exactly what the route just persisted on the message.platform,metadata.{thumbnail,title,description,type,ogType},fetchStatus,fetchedAt), the{links:[]}short-circuit, a failed entry ({url,platform,fetchStatus}only), Instagram's unmodelledcaption/derivedListextras, order across multiple links, the 401 owner-only path, and the old wrapper shape, which must now throw. They live inAPIClientMessagesTests(the messages domain file) rather than a new file, so noproject.pbxprojentry is added.GETon that route (:47-48) returns the same{ links }shape from stored metadata — no iOS code calls it, so there is nothing else decoding it wrongly.GET /api/link-metadata({ link: LinkMetadataItem }) was already correct.What this removes from #103
LinkMetadataItem.init(preview:)/.from(previews:)and their 10LinkMetadataConversionTestscases are deleted: with the route's entries already in the feed row's shape, there are no longer two shapes to bridge.MessageLinkPreviewhad no other consumer in the app or tests, so it is deleted too rather than left as a dead type, andLinkMetadataConversionTests.swiftis unslotted fromproject.pbxproj(hand-edited, no re-sort). The other half of #103 —AppDataStore.applyLinkMetadata,Message.linkMetadatabecomingvar, theComposeViewwiring and the 6 store tests — is untouched and is what this makes work.Testing
18ABC526-B80A-406E-986C-8F29844B248E, serialized, E2E skipped, private DerivedData: 1169 tests, 0 failures. (1172 on the feat(messages): apply refreshed link metadata to posted row #103 base − 11 deleted (10 conversion + 1 old wrapper-shaped test inAPIClientGapPhasesTests) + 8 new.) Cleanbuild-for-testing: no errors, no new warnings.metadata-wrapper decode (with entries retyped toLinkMetadataItem) failstest_refreshMessageMetadata_routeShape_decodesNonEmptyLinks,…_failedEntry_keepsUrlWithNilMetadata,…_instagramExtras_decodeWithoutError,…_multipleLinks_preservesRouteOrderand…_legacyMetadataWrapper_throwsDecodingError— 17 assertion failures across the metadata section. The mutation was reverted and the full suite re-run green.test_refreshMessageMetadata_legacyMetadataWrapper_throwsDecodingError: it asserts the pre-Bug: refreshMessageMetadata decodes a response shape the route never sends — always returns [] #104 body no longer decodes at all, rather than quietly yielding[].ComposeLinkDetectionTests' two publish-guard fixtures were route-shaped as well; they assert request issuance, so they passed either way, but leaving an invented shape in a fixture is the defect this issue is about.Not verified
route.ts,metadata-fetcher.ts,serialize.ts,lib/types/index.ts), not from a captured production response.FeedViewmirrorsstore.feedMessagesinto local@Stateand syncs oncount, so an in-placelinkMetadatachange isn't picked up until a full reload. The store and cache are correct; the view's private copy is stale. Not touched here.fetchedAt,ogType,captionandderivedListare sent by the route but deliberately not modelled onLinkMetadataItem(nothing renders them); the tests only prove they don't break the decode.🤖 Generated with Claude Code