fix: unified profile - global self profile#1131
Conversation
|
Claude finished @cameronvoell's task —— View job PR Review: Unified Profile - Global Self ProfileCompleted comprehensive code review focusing on code quality, potential bugs, performance, security, and test coverage. 🏗️ Architecture OverviewThis PR implements a major architectural change, migrating from per-conversation
Verdict: ✅ Clean architecture with proper separation of concerns 🔴 Critical Issues1. Self-Profile Handling in NSE (High Severity)File: Issue: The snapshot builder reads // Line 182-186
let profiles = try DBProfile.fetchAll(db, inboxIds: memberInboxIds)
return try profiles.compactMap { profile -> MemberProfile? in
let avatar = try DBProfileAvatar.fetchOne(db, inboxId: profile.inboxId, conversationId: conversationId)
return profile.snapshotMemberProfile(avatar: avatar)
}Impact: New members won't see the conversation creator's profile when the creator sends the initial snapshot. Fix Required: private static func fetchDBProfiles(...) async throws -> [MemberProfile] {
guard let databaseReader else { return [] }
return try await databaseReader.read { db in
let selfInboxId = try? DBInbox.currentInboxId(db)
var profiles = try DBProfile.fetchAll(db, inboxIds: memberInboxIds)
// Include self-profile if in member list
if let selfInboxId, memberInboxIds.contains(selfInboxId),
let selfProfile = try DBSelfProfile.fetchOne(db) {
// Convert DBSelfProfile to DBProfile for snapshot
let selfAsProfile = DBProfile(/* ... */)
profiles.append(selfAsProfile)
}
return try profiles.compactMap { /* ... */ }
}
}2. Avatar Clearing Semantics (Medium Severity)File: Issue: Avatar disposition logic differs between
Problem: If user clears their avatar and sends a ProfileUpdate, then someone sends a ProfileSnapshot containing the old cached avatar, the snapshot's Root Cause: Fix Options:
3. Contact Sync Breakage (High Severity)File: Issue: Both files still write to
Impact: Contact profile updates won't propagate to the canonical stores. Users will see stale contact names/avatars in conversations. Fix Required: Replace 4. Asset Renewal Collector Reading Deleted Table (High Severity)File: Issue: The collector still reads Fix Required: Switch to // Replace memberProfile join with profileAvatar
static let profileAvatar: HasOneAssociation<DBConversationMember, DBProfileAvatar> = hasOne(
DBProfileAvatar.self,
using: ForeignKey([Columns.inboxId], to: [DBProfileAvatar.Columns.inboxId])
)5. Profile Staleness from Snapshot Precedence (Medium Severity)File: Issue:
However, if the order is reversed:
Verdict: Current behavior is correct - ProfileUpdate should always beat ProfileSnapshot. Not a bug.
|
| Issue | Severity | Fixed? | Impact |
|---|---|---|---|
| NSE self-profile exclusion | 🔴 High | ❌ | New members won't see creator's profile |
| Avatar clear → snapshot restore | 🟡 Medium | ❌ | Cleared avatars can resurrect |
| Contact sync → memberProfile | 🔴 High | ❌ | Contact updates won't propagate |
| Asset renewal reads deleted table | 🔴 High | ❌ | Avatar renewal breaks |
| appData members missing DBProfile | 🟡 Low | ❌ | Agents/invited members missing canonical data |
✅ Approval Recommendation
Approve after critical fixes. The architecture is sound, but the 4 high-severity issues must be addressed:
- Fix
ProfileSnapshotBuilder.fetchDBProfilesto include self - Update
ContactSyncCoordinator+ContactsWriterto write to canonical stores - Update
AssetRenewalURLCollectorto read fromDBProfileAvatar - Add test coverage for NSE snapshot + contact sync flows
The medium-severity avatar clearing issue can be documented as expected behavior or fixed in a follow-up.
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
How to use the Graphite Merge QueueAdd the label merge-queue to this PR to add it to the merge queue. You must have a Graphite account in order to use the merge queue. Sign up using this link. An organization admin has enabled the Graphite Merge Queue in this repository. Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue. This stack of pull requests is managed by Graphite. Learn more about stacking. |
| guard let databaseReader else { return [] } | ||
| let rows = try await databaseReader.read { db in | ||
| try DBMemberProfile.fetchAll(db, conversationId: conversationId, inboxIds: memberInboxIds) | ||
| return try await databaseReader.read { db in |
There was a problem hiding this comment.
🟡 Medium ProfileMessages/ProfileSnapshotBuilder.swift:181
fetchDBProfiles fetches only DBProfile rows, but the local user's canonical profile is stored in DBSelfProfile, not DBProfile. When this installation sends a ProfileSnapshot, its own member entry is missing from dbProfiles unless a recent ProfileUpdate or prior snapshot for itself still falls within the last 500 scanned messages. As a result, new joiners can receive a snapshot that omits the creator's name and avatar, rendering them as an unknown member. Consider including the local user's DBSelfProfile (with its DBProfileAvatar) in the fetched profiles so the sender's own identity is always represented.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Profiles/ProfileMessages/ProfileSnapshotBuilder.swift around line 181:
`fetchDBProfiles` fetches only `DBProfile` rows, but the local user's canonical profile is stored in `DBSelfProfile`, not `DBProfile`. When this installation sends a `ProfileSnapshot`, its own member entry is missing from `dbProfiles` unless a recent `ProfileUpdate` or prior snapshot for itself still falls within the last 500 scanned messages. As a result, new joiners can receive a snapshot that omits the creator's name and avatar, rendering them as an unknown member. Consider including the local user's `DBSelfProfile` (with its `DBProfileAvatar`) in the fetched profiles so the sender's own identity is always represented.
| memberKind: update.memberKind.dbMemberKind, | ||
| metadata: metadata.isEmpty ? nil : metadata, | ||
| receivedAt: receivedAt | ||
| ), |
There was a problem hiding this comment.
🟡 Medium Inboxes/MessagingService+PushNotifications.swift:899
Passing selfInboxId: currentInboxId into ProfileInboundApplier.apply causes the applier to return early for every self-authored ProfileUpdate in the NSE, before any canonical DBProfile or DBProfileAvatar rows are written. On a fresh install or secondary device with an empty profile store, replaying your own history no longer seeds your existing name/avatar from the network, so your local self profile stays blank until you edit it manually. The same regression exists in the sibling processProfileSnapshotInNSE path. Consider passing nil for selfInboxId in the NSE (or otherwise ensuring self echoes still seed the canonical profile) so history replay can populate the local self profile.
Also found in 1 other location(s)
ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift:289
Passing
currentInboxIdintoprocessProfileSnapshotmakesProfileInboundApplier.applydrop the local user's own row from every inbound snapshot. That means a fresh install/device never backfills its ownDBProfileAvatarslot from snapshots. Later, whenProfilePublisher.processNameOnlyJobrepublishes just a name change, it reads no existing avatar for that conversation and sendsavatar: nil, whichProfileUpdatesemantics treat as an explicit clear. Renaming yourself from a device that joined via snapshot will therefore clear your avatar for other members in that conversation.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Inboxes/MessagingService+PushNotifications.swift around line 899:
Passing `selfInboxId: currentInboxId` into `ProfileInboundApplier.apply` causes the applier to return early for every self-authored `ProfileUpdate` in the NSE, before any canonical `DBProfile` or `DBProfileAvatar` rows are written. On a fresh install or secondary device with an empty profile store, replaying your own history no longer seeds your existing name/avatar from the network, so your local self profile stays blank until you edit it manually. The same regression exists in the sibling `processProfileSnapshotInNSE` path. Consider passing `nil` for `selfInboxId` in the NSE (or otherwise ensuring self echoes still seed the canonical profile) so history replay can populate the local self profile.
Also found in 1 other location(s):
- ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift:289 -- Passing `currentInboxId` into `processProfileSnapshot` makes `ProfileInboundApplier.apply` drop the local user's own row from every inbound snapshot. That means a fresh install/device never backfills its own `DBProfileAvatar` slot from snapshots. Later, when `ProfilePublisher.processNameOnlyJob` republishes just a name change, it reads no existing avatar for that conversation and sends `avatar: nil`, which `ProfileUpdate` semantics treat as an explicit clear. Renaming yourself from a device that joined via snapshot will therefore clear your avatar for other members in that conversation.
|
|
||
| if let snapshot = resolvedSnapshot { | ||
| for memberProfile in snapshot.profiles { | ||
| if let resolvedSnapshot { |
There was a problem hiding this comment.
🟡 Medium Writers/ConversationWriter.swift:1098
processProfileMessagesFromHistory feeds every ProfileSnapshot profile into ProfileInboundApplier with receivedAt: resolvedSnapshot.sentAt, and ProfileMerge uses that timestamp for .profileSnapshot tie-breaking. A ProfileSnapshot is rebuilt from the sender's cached DB rows and prior fallback snapshots, so it can carry stale names/avatars for other members whose authoritative ProfileUpdate has already aged out of the 500-message window. Because ProfileMerge compares sentAt values, a recently re-published but stale snapshot wins over the older, accurate profile data already in DBProfile/DBProfileAvatar and then blocks correction from earlier snapshots, producing incorrect member names/avatars after history replay. Consider gating snapshot-sourced profile writes so they only fill slots that are still empty (matching the old existing?.name == nil, existing?.avatar == nil guard), or skip the snapshot branch entirely once per-member ProfileUpdate data is already canonical.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift around line 1098:
`processProfileMessagesFromHistory` feeds every `ProfileSnapshot` profile into `ProfileInboundApplier` with `receivedAt: resolvedSnapshot.sentAt`, and `ProfileMerge` uses that timestamp for `.profileSnapshot` tie-breaking. A `ProfileSnapshot` is rebuilt from the sender's cached DB rows and prior fallback snapshots, so it can carry stale names/avatars for other members whose authoritative `ProfileUpdate` has already aged out of the 500-message window. Because `ProfileMerge` compares `sentAt` values, a recently re-published but stale snapshot wins over the older, accurate profile data already in `DBProfile`/`DBProfileAvatar` and then blocks correction from earlier snapshots, producing incorrect member names/avatars after history replay. Consider gating snapshot-sourced profile writes so they only fill slots that are still empty (matching the old `existing?.name == nil, existing?.avatar == nil` guard), or skip the snapshot branch entirely once per-member `ProfileUpdate` data is already canonical.
| ]) | ||
| .including(required: DBConversationMember.memberProfile) | ||
| .including(optional: DBConversationMember.inviterProfile) | ||
| .including(optional: DBConversationMember.profile) |
There was a problem hiding this comment.
🟠 High Repositories/ConversationsRepository.swift:247
detailedConversationQuery() now selects from DBConversationMember.profile and DBConversationMember.avatarSlot (optional joins) instead of the legacy memberProfile association. The write paths in ConversationWriter.persist and InviteJoinRequestsManager still only populate DBMemberProfile, so on a fresh or newly synced conversation the profile and avatarSlot joins return nil and member names/avatars silently disappear — conversations render as generic "Somebody" with a blank avatar even though the profile data exists in the database. Update the remaining write paths to also populate DBConversationMember.profile/avatarSlot, or add a migration/fallback so existing DBMemberProfile data is visible through the new associations.
Also found in 7 other location(s)
ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMember.swift:50
DBConversationMember.profilenow joins onlyDBProfilebyinboxId, but the existing write paths still populate member identity inDBMemberProfile(for exampleConversationWritersavesDBMemberProfilefor conversation members, andInviteJoinRequestsManager/IncomingMessageWriteralso write per-conversation member profiles).DBConversationMemberProfileWithRole.fetch*includes this new association, andhydrateMessagePreviewnow readsrow.profile?.name/row.isAgent; when noDBProfilerow exists yet, those values becomenil/falseand normal conversations render remote senders as "Somebody" and non-agents even though the member's stored per-conversation profile has a name/kind.
ConvosCore/Sources/ConvosCore/Inboxes/MessagingService+PushNotifications.swift:657
isMemberAgentnow reads onlyDBProfile, but agent identity can still be introduced throughInviteJoinRequestsManager, which persists the joiner'smemberKindintoDBMemberProfile/DBContactwithout creating a canonicalDBProfilerow. If that newly joined agent sends an HTML attachment before any profile message arrives, this method returnsfalseand the push notification falls back to the generic attachment text instead of the agent-specific "made you a thing" body.
ConvosCore/Sources/ConvosCore/Storage/Repositories/MessagesRepository.swift:850
fetchLightweightConversation()switched the creator/member joins toDBConversationMember.profile/avatarSlot, but the app still writes many profile updates only into legacyDBMemberProfilerows (ConversationWriter.persistapp-data fill,MyProfileWriter,InviteJoinRequestsManager, etc.). WhencomposeMessages()loads a conversation before an inbound profile event has copied that data into the canonical tables, the lightweight conversation comes back withnilprofiles, so message composition loses member names/avatars and falls back to empty/"Somebody"profiles on a normal code path.
ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift:1078
processProfileMessagesFromHistoryno longer writes any of the replayed profile data back intoDBMemberProfile; it only updates the newDBProfile/DBProfileAvatartables throughProfileInboundApplier. Several live paths still readDBMemberProfiledirectly, includingContactSyncCoordinatorwhen it buildsContactProfileSnapshots for a conversation. On a fresh install/pairing or any catch-up that relies on historicalProfileUpdate/ProfileSnapshotmessages, thoseDBMemberProfilerows stay blank, so contact sync imports members without names, avatars, or agent verification even though the history replay just decoded that data.
ConvosCore/Sources/ConvosCore/Crypto/AgentVerificationWriter.swift:25
reverifyUnverifiedAgentsnow upgrades only the canonicalDBProfilerow (updated.save(db)), but several live agent features still read verification from legacyDBMemberProfilerows. For example,AgentBuilderConnectionGrantReplayer.verifiedAgentInboxIdsfiltersDBMemberProfilefor verified agents, so an agent that is reverified here remains invisible there and its pending connection grants never replay.
ConvosCore/Sources/ConvosCore/Storage/Writers/IncomingMessageWriter.swift:392
bootstrapSenderProfilenow trusts onlyDBProfile, but batch catch-up persists regular messages before any profile-history replay populates that table. InBatchCatchUp.persistPreparedEntries,messageWriter.persist(...)runs immediately afterconversationWriter.persist(...), whileConversationWriter.runPostPersistSideEffectsappliesProfileInboundApplieronly later on a detached task. On a cold launch/backlog replay, a real verified agent'sCloudConnectionGrantRequesttherefore sees noDBProfile,senderVerifiedbecomesfalse, andpersistsilently drops the request even though the sender is legitimate.
docs/plans/2026-06-29-profile-table-implementation.md:725
The follow-up plan to backfill-then-drop
member_profilein the same PR (docs/plans/2026-06-29-profile-table-implementation.md:725) is unsafe because the current app still has multiple live runtime paths that queryDBMemberProfile, not just vestigial self-editing. For exampleAssetRenewalURLCollector.collectRenewableAssets()enumerates profile avatars fromDBMemberProfile,ContactSyncCoordinator.syncConversationreads every member'sDBMemberProfileto build contact snapshots, andAgentTimezonePublisher.republishTimezoneForAgentConversations()finds the user's conversations by fetchingDBMemberProfilerows. Dropping the table under those callers will turn those paths into SQL "no such table: memberProfile" failures and break avatar renewal / contact sync / timezone republish after migration.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Storage/Repositories/ConversationsRepository.swift around line 247:
`detailedConversationQuery()` now selects from `DBConversationMember.profile` and `DBConversationMember.avatarSlot` (optional joins) instead of the legacy `memberProfile` association. The write paths in `ConversationWriter.persist` and `InviteJoinRequestsManager` still only populate `DBMemberProfile`, so on a fresh or newly synced conversation the `profile` and `avatarSlot` joins return `nil` and member names/avatars silently disappear — conversations render as generic `"Somebody"` with a blank avatar even though the profile data exists in the database. Update the remaining write paths to also populate `DBConversationMember.profile`/`avatarSlot`, or add a migration/fallback so existing `DBMemberProfile` data is visible through the new associations.
Also found in 7 other location(s):
- ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMember.swift:50 -- `DBConversationMember.profile` now joins only `DBProfile` by `inboxId`, but the existing write paths still populate member identity in `DBMemberProfile` (for example `ConversationWriter` saves `DBMemberProfile` for conversation members, and `InviteJoinRequestsManager`/`IncomingMessageWriter` also write per-conversation member profiles). `DBConversationMemberProfileWithRole.fetch*` includes this new association, and `hydrateMessagePreview` now reads `row.profile?.name` / `row.isAgent`; when no `DBProfile` row exists yet, those values become `nil`/`false` and normal conversations render remote senders as "Somebody" and non-agents even though the member's stored per-conversation profile has a name/kind.
- ConvosCore/Sources/ConvosCore/Inboxes/MessagingService+PushNotifications.swift:657 -- `isMemberAgent` now reads only `DBProfile`, but agent identity can still be introduced through `InviteJoinRequestsManager`, which persists the joiner's `memberKind` into `DBMemberProfile`/`DBContact` without creating a canonical `DBProfile` row. If that newly joined agent sends an HTML attachment before any profile message arrives, this method returns `false` and the push notification falls back to the generic attachment text instead of the agent-specific "made you a thing" body.
- ConvosCore/Sources/ConvosCore/Storage/Repositories/MessagesRepository.swift:850 -- `fetchLightweightConversation()` switched the creator/member joins to `DBConversationMember.profile`/`avatarSlot`, but the app still writes many profile updates only into legacy `DBMemberProfile` rows (`ConversationWriter.persist` app-data fill, `MyProfileWriter`, `InviteJoinRequestsManager`, etc.). When `composeMessages()` loads a conversation before an inbound profile event has copied that data into the canonical tables, the lightweight conversation comes back with `nil` profiles, so message composition loses member names/avatars and falls back to empty/`"Somebody"` profiles on a normal code path.
- ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift:1078 -- `processProfileMessagesFromHistory` no longer writes any of the replayed profile data back into `DBMemberProfile`; it only updates the new `DBProfile` / `DBProfileAvatar` tables through `ProfileInboundApplier`. Several live paths still read `DBMemberProfile` directly, including `ContactSyncCoordinator` when it builds `ContactProfileSnapshot`s for a conversation. On a fresh install/pairing or any catch-up that relies on historical `ProfileUpdate`/`ProfileSnapshot` messages, those `DBMemberProfile` rows stay blank, so contact sync imports members without names, avatars, or agent verification even though the history replay just decoded that data.
- ConvosCore/Sources/ConvosCore/Crypto/AgentVerificationWriter.swift:25 -- `reverifyUnverifiedAgents` now upgrades only the canonical `DBProfile` row (`updated.save(db)`), but several live agent features still read verification from legacy `DBMemberProfile` rows. For example, `AgentBuilderConnectionGrantReplayer.verifiedAgentInboxIds` filters `DBMemberProfile` for verified agents, so an agent that is reverified here remains invisible there and its pending connection grants never replay.
- ConvosCore/Sources/ConvosCore/Storage/Writers/IncomingMessageWriter.swift:392 -- `bootstrapSenderProfile` now trusts only `DBProfile`, but batch catch-up persists regular messages before any profile-history replay populates that table. In `BatchCatchUp.persistPreparedEntries`, `messageWriter.persist(...)` runs immediately after `conversationWriter.persist(...)`, while `ConversationWriter.runPostPersistSideEffects` applies `ProfileInboundApplier` only later on a detached task. On a cold launch/backlog replay, a real verified agent's `CloudConnectionGrantRequest` therefore sees no `DBProfile`, `senderVerified` becomes `false`, and `persist` silently drops the request even though the sender is legitimate.
- docs/plans/2026-06-29-profile-table-implementation.md:725 -- The follow-up plan to backfill-then-drop `member_profile` in the same PR (`docs/plans/2026-06-29-profile-table-implementation.md:725`) is unsafe because the current app still has multiple live runtime paths that query `DBMemberProfile`, not just vestigial self-editing. For example `AssetRenewalURLCollector.collectRenewableAssets()` enumerates profile avatars from `DBMemberProfile`, `ContactSyncCoordinator.syncConversation` reads every member's `DBMemberProfile` to build contact snapshots, and `AgentTimezonePublisher.republishTimezoneForAgentConversations()` finds the user's conversations by fetching `DBMemberProfile` rows. Dropping the table under those callers will turn those paths into SQL "no such table: memberProfile" failures and break avatar renewal / contact sync / timezone republish after migration.
| try await publisher.publish(avatarBytes: avatarBytes, priorityConversationId: priorityConversationId) | ||
| } | ||
|
|
||
| /// Seeds a freshly created or joined conversation with the current profile. |
There was a problem hiding this comment.
🟡 Medium Repository/ProfilesRepository.swift:259
publishMyProfileMetadata calls publisher.publish(avatarBytes: nil, ...), which enqueues hasAvatar == false jobs that supersede any older pending avatar jobs for the same conversation. If a queued avatar change is still waiting to be delivered (e.g., retrying after an upload/send failure, or pending across a restart), enqueueJob's supersedeOlderThan call deletes that pending avatar job, and processNameOnlyJob re-sends the previously published avatar instead — so the new avatar is silently lost. Consider avoiding superseding pending avatar jobs when issuing a metadata-only publish, or enqueueing metadata updates as a separate job type that doesn't replace avatar jobs.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfilesRepository.swift around line 259:
`publishMyProfileMetadata` calls `publisher.publish(avatarBytes: nil, ...)`, which enqueues `hasAvatar == false` jobs that supersede any older pending avatar jobs for the same conversation. If a queued avatar change is still waiting to be delivered (e.g., retrying after an upload/send failure, or pending across a restart), `enqueueJob`'s `supersedeOlderThan` call deletes that pending avatar job, and `processNameOnlyJob` re-sends the previously published avatar instead — so the new avatar is silently lost. Consider avoiding superseding pending avatar jobs when issuing a metadata-only publish, or enqueueing metadata updates as a separate job type that doesn't replace avatar jobs.
| try await session?.messagingServiceSync().profilesRepository().publishMyProfile( | ||
| displayName: resolvedName, | ||
| avatarBytes: imageData, | ||
| priorityConversationId: nil |
There was a problem hiding this comment.
🟠 High Profile/ProfileSettingsViewModel.swift:182
saveAndAwait() calls profilesRepository().publishMyProfile(displayName:resolvedName, avatarBytes:imageData, ...) even when the user deleted their photo and imageData is nil. In ProfilePublisher.publish, nil avatarBytes means "re-send the existing avatar," not "clear the avatar," so removing a photo in Settings leaves the old avatar published to every conversation. The local DBMyProfile is cleared, but other participants keep seeing the stale photo. Consider distinguishing a deliberate avatar removal from a name-only edit — e.g. by passing a sentinel or a dedicated delete API — so the publish path clears per-conversation avatars instead of re-sending them.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @Convos/Profile/ProfileSettingsViewModel.swift around line 182:
`saveAndAwait()` calls `profilesRepository().publishMyProfile(displayName:resolvedName, avatarBytes:imageData, ...)` even when the user deleted their photo and `imageData` is `nil`. In `ProfilePublisher.publish`, `nil` `avatarBytes` means "re-send the existing avatar," not "clear the avatar," so removing a photo in Settings leaves the old avatar published to every conversation. The local `DBMyProfile` is cleared, but other participants keep seeing the stale photo. Consider distinguishing a deliberate avatar removal from a name-only edit — e.g. by passing a sentinel or a dedicated delete API — so the publish path clears per-conversation avatars instead of re-sending them.
| // repository so it fans out to every conversation via | ||
| // the durable publisher. No image bytes are available on | ||
| // the adoption path, so only the display name is sent. | ||
| try await session.messagingService().profilesRepository().publishMyProfile( |
There was a problem hiding this comment.
🟡 Medium Conversations List/ConversationsViewModel.swift:395
In onApplyAdoptedProfile, the adopted avatar is saved to DBMyProfile with only imageAssetIdentifier (no imageData), and then publishMyProfile is called with avatarBytes: nil. Since ProfilePublisher only records an avatar source when avatarBytes is non-nil, the canonical profile store never gets the adopted photo. Any new conversation created on the paired device seeds only the display name and omits the avatar, even though the adopted profile had a photo. Consider passing the actual avatar image bytes into publishMyProfile so the fan-out carries the avatar, or load the adopted photo from imageAssetIdentifier before publishing.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @Convos/Conversations List/ConversationsViewModel.swift around line 395:
In `onApplyAdoptedProfile`, the adopted avatar is saved to `DBMyProfile` with only `imageAssetIdentifier` (no `imageData`), and then `publishMyProfile` is called with `avatarBytes: nil`. Since `ProfilePublisher` only records an avatar source when `avatarBytes` is non-nil, the canonical profile store never gets the adopted photo. Any new conversation created on the paired device seeds only the display name and omits the avatar, even though the adopted profile had a photo. Consider passing the actual avatar image bytes into `publishMyProfile` so the fan-out carries the avatar, or load the adopted photo from `imageAssetIdentifier` before publishing.
| conversationId: conversationId, | ||
| name: (global.name?.isEmpty ?? true) ? nil : global.name, | ||
| name: (selfRow.name?.isEmpty ?? true) ? nil : selfRow.name, | ||
| avatar: nil, |
There was a problem hiding this comment.
🟡 Medium Repositories/MyProfileRepository.swift:159
observedProfile always constructs the Profile with avatar: nil, so when ProfilePublisher persists the current user's avatar into DBProfileAvatar, fetch() and myProfilePublisher never surface it — the profile keeps showing no avatar or stale fallback data even after a successful upload. Consider reading the avatar row (e.g. DBProfileAvatarSource.fetchOne(db, inboxId:)) and passing its value into the returned Profile.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Storage/Repositories/MyProfileRepository.swift around line 159:
`observedProfile` always constructs the `Profile` with `avatar: nil`, so when `ProfilePublisher` persists the current user's avatar into `DBProfileAvatar`, `fetch()` and `myProfilePublisher` never surface it — the profile keeps showing no avatar or stale fallback data even after a successful upload. Consider reading the avatar row (e.g. `DBProfileAvatarSource.fetchOne(db, inboxId:)`) and passing its value into the returned `Profile`.
0215de0 to
e2b0536
Compare
564cbd8 to
4fa3f85
Compare
| inboxId: senderInboxId, | ||
| name: nil, | ||
| avatar: nil | ||
| event: ProfileInboundApplier.Incoming( |
There was a problem hiding this comment.
🟡 Medium Syncing/StreamProcessor.swift:488
processProfileUpdate and processProfileSnapshot now call only ProfileInboundApplier, which writes the canonical profile/profileAvatar tables but does not save a DBMemberProfile row. ContactSyncCoordinator.sync reads name, avatar, and agent-template fields from DBMemberProfile, so a member learned only through a streamed ProfileUpdate or ProfileSnapshot is added as a contact with nil name, avatar, and template metadata. The trigger is: receive a member's profile message, then hit the first-message contact sync for that conversation. Consider persisting a DBMemberProfile row alongside the canonical profile write, or updating ContactSyncCoordinator to read from the canonical profile table.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift around line 488:
`processProfileUpdate` and `processProfileSnapshot` now call only `ProfileInboundApplier`, which writes the canonical `profile`/`profileAvatar` tables but does not save a `DBMemberProfile` row. `ContactSyncCoordinator.sync` reads name, avatar, and agent-template fields from `DBMemberProfile`, so a member learned only through a streamed `ProfileUpdate` or `ProfileSnapshot` is added as a contact with nil name, avatar, and template metadata. The trigger is: receive a member's profile message, then hit the first-message contact sync for that conversation. Consider persisting a `DBMemberProfile` row alongside the canonical profile write, or updating `ContactSyncCoordinator` to read from the canonical `profile` table.
| @@ -787,24 +797,13 @@ struct MemberProfileCache { | |||
|
|
|||
| init( | |||
There was a problem hiding this comment.
🟡 Medium Repositories/MessagesRepository.swift:798
When a member leaves a conversation, their reactions on existing messages disappear from the transcript. MemberProfileCache now only indexes rows from DBConversationMember (active members). The reaction hydration path looks up each reaction's sender in the cache and drops reactions whose sender is not found, so any reaction authored by a former member is silently discarded rather than rendered with the sender's historical profile.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Storage/Repositories/MessagesRepository.swift around line 798:
When a member leaves a conversation, their reactions on existing messages disappear from the transcript. `MemberProfileCache` now only indexes rows from `DBConversationMember` (active members). The reaction hydration path looks up each reaction's sender in the cache and drops reactions whose sender is not found, so any reaction authored by a former member is silently discarded rather than rendered with the sender's historical profile.
| ProfileUpdate/Snapshot). | ||
|
|
||
| Ordering: | ||
| - [ ] `IncomingMessageWriter.bootstrapSenderProfile` reads `DBProfile`, but in |
There was a problem hiding this comment.
🟡 Medium plans/2026-06-29-profile-table-implementation.md:768
On cold-start backlog replay, BatchCatchUp persists messages before ProfileInboundApplier runs, but IncomingMessageWriter.bootstrapSenderProfile now checks only the canonical DBProfile table to decide whether to keep a CloudConnectionGrantRequest. When the backlog is from a verified agent, the canonical profile row has not been written yet at that point, so senderVerified is false and the grant request is silently dropped instead of being stored. The cleanup checklist already calls out landing the canonical write before the trusted-sender gate is checked; consider either reordering so ProfileInboundApplier runs before the trusted-sender gate in BatchCatchUp, or making the gate tolerate a pending profile during cold-start replay.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @docs/plans/2026-06-29-profile-table-implementation.md around line 768:
On cold-start backlog replay, `BatchCatchUp` persists messages before `ProfileInboundApplier` runs, but `IncomingMessageWriter.bootstrapSenderProfile` now checks only the canonical `DBProfile` table to decide whether to keep a `CloudConnectionGrantRequest`. When the backlog is from a verified agent, the canonical profile row has not been written yet at that point, so `senderVerified` is false and the grant request is silently dropped instead of being stored. The cleanup checklist already calls out landing the canonical write before the trusted-sender gate is checked; consider either reordering so `ProfileInboundApplier` runs before the trusted-sender gate in `BatchCatchUp`, or making the gate tolerate a pending profile during cold-start replay.
There was a problem hiding this comment.
🟡 Medium
Opening an existing conversation now emits a ProfileUpdate message every time. conversationStateManager(for:initialMemberInboxIds:) passes profileConversationSeeder into ConversationStateManager, and handleStateChange calls scheduleProfileSync on every .ready transition. The seeder invokes ProfilesRepository.publishMyProfileToConversation(_:) unconditionally — unlike the old MyProfileWriter.syncFromGlobalProfile(conversationId:), it does not check whether the conversation already has the current self profile. So merely opening an existing chat produces a redundant ProfileUpdate message and app-data write on every open. Consider guarding the seeder with a profile-already-present check, or skipping the seeder for the existing-conversation path.
Also found in 1 other location(s)
ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationStateManager.swift:219
scheduleProfileSyncnow launchesprofileConversationSeederdirectly in a detached task without the old per-conversationProfileSyncCoordinatorguard.ConversationStateMachine.State.readyis documented elsewhere in this PR as able to re-emit, so the same conversation can schedule multiple seeds concurrently. With the production seeder (ProfilesRepository.publishMyProfileToConversation) that enqueues and drains a publish job, two.readyemissions can both publish the self profile, causing duplicate profile-update uploads/messages for the same conversation.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Messaging/MessagingService.swift around line 268:
Opening an existing conversation now emits a `ProfileUpdate` message every time. `conversationStateManager(for:initialMemberInboxIds:)` passes `profileConversationSeeder` into `ConversationStateManager`, and `handleStateChange` calls `scheduleProfileSync` on every `.ready` transition. The seeder invokes `ProfilesRepository.publishMyProfileToConversation(_:)` unconditionally — unlike the old `MyProfileWriter.syncFromGlobalProfile(conversationId:)`, it does not check whether the conversation already has the current self profile. So merely opening an existing chat produces a redundant `ProfileUpdate` message and app-data write on every open. Consider guarding the seeder with a profile-already-present check, or skipping the seeder for the existing-conversation path.
Also found in 1 other location(s):
- ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationStateManager.swift:219 -- `scheduleProfileSync` now launches `profileConversationSeeder` directly in a detached task without the old per-conversation `ProfileSyncCoordinator` guard. `ConversationStateMachine.State.ready` is documented elsewhere in this PR as able to re-emit, so the same conversation can schedule multiple seeds concurrently. With the production seeder (`ProfilesRepository.publishMyProfileToConversation`) that enqueues and drains a publish job, two `.ready` emissions can both publish the self profile, causing duplicate profile-update uploads/messages for the same conversation.
| updateDisplayNameTask?.cancel() | ||
| beginUpdate() | ||
| let unsafeWriter = myProfileWriter | ||
| let repository = messagingService.profilesRepository() |
There was a problem hiding this comment.
🟡 Medium Profile/MyProfileViewModel.swift:117
update(displayName:conversationId:) calls messagingService.profilesRepository().publishMyProfile(...), but the durable publisher only fans out to conversations after startProfileServices() attaches a ProfilePublishSession. If a profile edit happens before that background startup task runs, ProfilePublisher.publish sees session == nil, computes conversationIds as [], and enqueues no jobs — so the name update lands in the local self store but never reaches any existing conversation. Consider guarding callers on publish-session readiness or documenting this race window.
Also found in 1 other location(s)
Convos/Conversation Detail/ConversationViewModel.swift:1283
ConversationViewModelnow buildsMyProfileViewModelwith the sharedmessagingServiceinstead of the old per-conversation writer.MyProfileViewModelpublishes edits throughmessagingService.profilesRepository(), butSessionManagerstartsstartProfileServices()on a backgroundTaskafter initialization. If the user edits their profile before that async startup binds theProfilePublishersession,ProfilesRepository.publishMyProfile(...)seessession == nil, enqueues no conversation jobs, and only updates the local self-profile row. The edit looks saved locally but is never sent to the current conversation or other members.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @Convos/Profile/MyProfileViewModel.swift around line 117:
`update(displayName:conversationId:)` calls `messagingService.profilesRepository().publishMyProfile(...)`, but the durable publisher only fans out to conversations after `startProfileServices()` attaches a `ProfilePublishSession`. If a profile edit happens before that background startup task runs, `ProfilePublisher.publish` sees `session == nil`, computes `conversationIds` as `[]`, and enqueues no jobs — so the name update lands in the local self store but never reaches any existing conversation. Consider guarding callers on publish-session readiness or documenting this race window.
Also found in 1 other location(s):
- Convos/Conversation Detail/ConversationViewModel.swift:1283 -- `ConversationViewModel` now builds `MyProfileViewModel` with the shared `messagingService` instead of the old per-conversation writer. `MyProfileViewModel` publishes edits through `messagingService.profilesRepository()`, but `SessionManager` starts `startProfileServices()` on a background `Task` after initialization. If the user edits their profile before that async startup binds the `ProfilePublisher` session, `ProfilesRepository.publishMyProfile(...)` sees `session == nil`, enqueues no conversation jobs, and only updates the local self-profile row. The edit looks saved locally but is never sent to the current conversation or other members.
4fa3f85 to
24d5edb
Compare
e2b0536 to
cf2d0ec
Compare
| migrator.registerMigration("createProfileAvatarLatestView", migrate: Self.createProfileAvatarLatestView) | ||
| } | ||
|
|
||
| /// Creates the `profileAvatarLatest` view: the most recently updated |
There was a problem hiding this comment.
🟡 Medium Storage/SharedDatabaseMigrator.swift:226
The view uses GROUP BY inboxId while selecting bare columns like conversationId, url, salt, and nonce. When an inbox has multiple rows with the same max updatedAt, SQLite returns bare columns from an arbitrary row in the group, so the view exposes a mix of columns from different avatar rows. Because ProfileBackfill writes every legacy avatar for an inbox at the same epoch floor timestamp, backfilled users with avatars in multiple conversations get nondeterministic results and may render the wrong avatar. Consider selecting a single deterministic row per inbox, e.g. with ROW_NUMBER() OVER (PARTITION BY inboxId ORDER BY updatedAt DESC) or an additional tie-breaker on conversationId.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Storage/SharedDatabaseMigrator.swift around line 226:
The view uses `GROUP BY inboxId` while selecting bare columns like `conversationId`, `url`, `salt`, and `nonce`. When an inbox has multiple rows with the same max `updatedAt`, SQLite returns bare columns from an arbitrary row in the group, so the view exposes a mix of columns from different avatar rows. Because `ProfileBackfill` writes every legacy avatar for an inbox at the same epoch `floor` timestamp, backfilled users with avatars in multiple conversations get nondeterministic results and may render the wrong avatar. Consider selecting a single deterministic row per inbox, e.g. with `ROW_NUMBER() OVER (PARTITION BY inboxId ORDER BY updatedAt DESC)` or an additional tie-breaker on `conversationId`.
| private func processProfileUpdate(_ message: DecodedMessage, conversationId: String) async { | ||
| private func processProfileUpdate(_ message: DecodedMessage, conversationId: String, currentInboxId: String) async { | ||
| guard let update = try? ProfileUpdateCodec().decode(content: message.encodedContent) else { | ||
| Log.warning("Failed to decode ProfileUpdate from message \(message.id)") |
There was a problem hiding this comment.
🟡 Medium Syncing/StreamProcessor.swift:472
processProfileUpdate now routes through ProfileInboundApplier.apply, which writes only DBProfile/DBProfileAvatar and never mirrors the change onto DBContact. The removed ContactsWriter.saveMemberProfileAndMirrorToContactInTransaction call updated the contact row on every inbound profile update. After this change, a contact's displayName/avatar is frozen at the value they had when first added — subsequent ProfileUpdate messages from that person update the canonical profile but leave DBContact stale, so the contact list and contact detail surfaces show the old name/avatar indefinitely.
Consider calling ContactsWriter to mirror the resolved identity onto DBContact after ProfileInboundApplier.apply completes, or document why contacts are intentionally not kept in sync.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift around line 472:
`processProfileUpdate` now routes through `ProfileInboundApplier.apply`, which writes only `DBProfile`/`DBProfileAvatar` and never mirrors the change onto `DBContact`. The removed `ContactsWriter.saveMemberProfileAndMirrorToContactInTransaction` call updated the contact row on every inbound profile update. After this change, a contact's `displayName`/avatar is frozen at the value they had when first added — subsequent `ProfileUpdate` messages from that person update the canonical profile but leave `DBContact` stale, so the contact list and contact detail surfaces show the old name/avatar indefinitely.
Consider calling `ContactsWriter` to mirror the resolved identity onto `DBContact` after `ProfileInboundApplier.apply` completes, or document why contacts are intentionally not kept in sync.
| inboxId: inboxId, | ||
| source: .profileUpdate, | ||
| name: entry.update.hasName ? entry.update.name : nil, | ||
| avatar: .fillIfPresent(entry.update.hasEncryptedImage ? entry.update.encryptedImage : nil), |
There was a problem hiding this comment.
🟡 Medium Writers/ConversationWriter.swift:1088
processProfileMessagesFromHistory passes ProfileUpdate avatar data to ProfileInboundApplier as .fillIfPresent(...) instead of .addressed(...). For a ProfileUpdate, a missing encryptedImage means the sender cleared their avatar, but .fillIfPresent maps that to IncomingAvatar.silent, so the existing DBProfileAvatar row is left untouched. After a cold start or history catch-up, a contact who removed their avatar will still display the stale avatar until a fresh live update arrives. Consider using .addressed(...) for ProfileUpdate events so an absent image clears the stored avatar.
| avatar: .fillIfPresent(entry.update.hasEncryptedImage ? entry.update.encryptedImage : nil), | |
| avatar: .addressed(entry.update.hasEncryptedImage ? entry.update.encryptedImage : nil), |
Also found in 1 other location(s)
ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationStateManager.swift:98
Replacing
MyProfileWriter.syncFromGlobalProfilewithprofileConversationSeederalso drops the avatar-clear path. The old code explicitly calledupdate(avatar: nil, ...)whenDBMyProfile.imageDataand its digest were cleared, butProfilesRepository.publishMyProfileToConversationonly enqueues a name-only job.ProfilePublisher.processNameOnlyJobthen reuses any existingprofileStore.avatar(...)for that conversation, so opening a conversation after the user removed their global photo re-sends the stale per-conversation avatar instead of clearing it.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift around line 1088:
`processProfileMessagesFromHistory` passes `ProfileUpdate` avatar data to `ProfileInboundApplier` as `.fillIfPresent(...)` instead of `.addressed(...)`. For a `ProfileUpdate`, a missing `encryptedImage` means the sender cleared their avatar, but `.fillIfPresent` maps that to `IncomingAvatar.silent`, so the existing `DBProfileAvatar` row is left untouched. After a cold start or history catch-up, a contact who removed their avatar will still display the stale avatar until a fresh live update arrives. Consider using `.addressed(...)` for `ProfileUpdate` events so an absent image clears the stored avatar.
Also found in 1 other location(s):
- ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationStateManager.swift:98 -- Replacing `MyProfileWriter.syncFromGlobalProfile` with `profileConversationSeeder` also drops the avatar-clear path. The old code explicitly called `update(avatar: nil, ...)` when `DBMyProfile.imageData` and its digest were cleared, but `ProfilesRepository.publishMyProfileToConversation` only enqueues a name-only job. `ProfilePublisher.processNameOnlyJob` then reuses any existing `profileStore.avatar(...)` for that conversation, so opening a conversation after the user removed their global photo re-sends the stale per-conversation avatar instead of clearing it.
| .including(optional: DBConversationMember.inviterProfile) | ||
| .including(optional: DBConversationMember.profile) | ||
| .including(optional: DBConversationMember.avatarSlot) | ||
| .including(optional: DBConversationMember.inviterProfileIdentity) |
There was a problem hiding this comment.
🟡 Medium Database Models/DBConversationMemberProfileWithRole.swift:60
The query joins inviterProfile via DBConversationMember.inviterProfileIdentity, but self identities aren't stored in DBProfile, so when the current user is the inviter the association resolves to nil and hydrateConversationMember() returns invitedBy: nil. Callers like composeAgentTemplateConversations then misclassify conversations the current user added an agent to as addedByOthers. Consider joining the inviter through an association that includes the local user's own identity, or falling back to the current inbox id when the inviter is self.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMemberProfileWithRole.swift around line 60:
The query joins `inviterProfile` via `DBConversationMember.inviterProfileIdentity`, but self identities aren't stored in `DBProfile`, so when the current user is the inviter the association resolves to `nil` and `hydrateConversationMember()` returns `invitedBy: nil`. Callers like `composeAgentTemplateConversations` then misclassify conversations the current user added an agent to as `addedByOthers`. Consider joining the inviter through an association that includes the local user's own identity, or falling back to the current inbox id when the inviter is self.
24d5edb to
aa29d71
Compare
f51e630 to
0af531c
Compare
| existing: existingAvatar, | ||
| inboxId: inboxId, | ||
| conversationId: conversationId, |
There was a problem hiding this comment.
🟡 Medium Repository/ProfileInboundApplier.swift:79
avatarEvent returns .explicitClear when an .addressed image ref is non-nil but invalid (image.isValid is false). So a ProfileUpdate that sets hasEncryptedImage = true but carries a malformed URL/salt/nonce overwrites the existing avatar slot and makes the avatar disappear. Malformed image refs should be treated as absent, not as a destructive clear — returning .silent here avoids silently dropping the avatar on corrupt updates.
| existing: existingAvatar, | |
| inboxId: inboxId, | |
| conversationId: conversationId, | |
| case let .addressed(image): | |
| guard image?.isValid == true else { return .silent } | |
| return .set(url: image!.url, salt: image!.salt, nonce: image!.nonce, key: existingKey ?? fallbackKey) |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileInboundApplier.swift around lines 79-81:
`avatarEvent` returns `.explicitClear` when an `.addressed` image ref is non-nil but invalid (`image.isValid` is false). So a `ProfileUpdate` that sets `hasEncryptedImage = true` but carries a malformed URL/salt/nonce overwrites the existing avatar slot and makes the avatar disappear. Malformed image refs should be treated as absent, not as a destructive clear — returning `.silent` here avoids silently dropping the avatar on corrupt updates.
| // repository so it fans out to every conversation via | ||
| // the durable publisher. No image bytes are available on | ||
| // the adoption path, so only the display name is sent. | ||
| try await session.messagingService().profilesRepository().publishMyProfile( |
There was a problem hiding this comment.
🟠 High Conversations List/ConversationsViewModel.swift:395
The pairing-adoption path calls profilesRepository().publishMyProfile(...) with the adopted displayName, but when displayName is nil the method skips updateSelfProfile and only publishes the avatar. ProfilePublisher then reads the existing self-profile row via selfProfileStore.load() and re-sends whatever stale name/metadata is still in that table to every conversation. Because refreshAfterPairingCompleted() / deleteAllInboxes() do not clear the canonical selfProfile table, a device that previously belonged to another account republishes the old account's identity when the adoption share payload carries no display name.
Also found in 1 other location(s)
Convos/Profile/ProfileSettingsViewModel.swift:182
saveAndAwait()now callsprofilesRepository().publishMyProfile(...)immediately afterwriter.save(...).publishMyProfileupdatesDBSelfProfilefrom the repository actor's in-memorycachedSelf, and when profile services have not finishedwarmUp()yetcachedSelfis stillnil. In that window, saving a display name writes a newDBSelfProfilerow with only the name andmetadata == nil, overwriting any existing self metadata thatProfileBackfillhad just seeded fromDBMyProfile. The next publish then sends the user profile without its metadata until it is re-authored.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @Convos/Conversations List/ConversationsViewModel.swift around line 395:
The pairing-adoption path calls `profilesRepository().publishMyProfile(...)` with the adopted `displayName`, but when `displayName` is `nil` the method skips `updateSelfProfile` and only publishes the avatar. `ProfilePublisher` then reads the existing self-profile row via `selfProfileStore.load()` and re-sends whatever stale name/metadata is still in that table to every conversation. Because `refreshAfterPairingCompleted()` / `deleteAllInboxes()` do not clear the canonical `selfProfile` table, a device that previously belonged to another account republishes the old account's identity when the adoption share payload carries no display name.
Also found in 1 other location(s):
- Convos/Profile/ProfileSettingsViewModel.swift:182 -- `saveAndAwait()` now calls `profilesRepository().publishMyProfile(...)` immediately after `writer.save(...)`. `publishMyProfile` updates `DBSelfProfile` from the repository actor's in-memory `cachedSelf`, and when profile services have not finished `warmUp()` yet `cachedSelf` is still `nil`. In that window, saving a display name writes a new `DBSelfProfile` row with only the name and `metadata == nil`, overwriting any existing self metadata that `ProfileBackfill` had just seeded from `DBMyProfile`. The next publish then sends the user profile without its metadata until it is re-authored.
|
folded into #1126 |

Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.Note
Replace per-conversation
DBMemberProfilewith a globalDBProfilefor all profile reads and writesProfileInboundApplieras the single entry point for writing inbound profile events (update, snapshot, history) to the canonicalDBProfileandDBProfileAvatartables, replacing scattered directDBMemberProfilemutations acrossStreamProcessor,MessagingService,ConversationWriter, andIncomingMessageWriter.DBConversationMember.profile(hasOne toDBProfile) andDBConversationMember.avatarSlot(hasOne toDBProfileAvatar) associations, and removes the oldmemberProfileandinviterProfileassociations that targetedDBMemberProfile.ConversationsRepository,MessagesRepository,DBConversationMemberProfileWithRole,DBConversation, andDBMessage— to join the unified profile tables instead ofDBMemberProfile.profileMemberMirrorandprofileShadowComparatorfromMessagingService; replacesstartProfileMirroring/stopProfileMirroringwithstartProfileServices/stopProfileServicesthat only run backfill and bind the publish session.AgentVerificationWriter.reverifyUnverifiedAgentsnow promotesDBProfilerows and markshasHadVerifiedAgenton every conversation the agent belongs to, not just one.DBProfilerow will appear with nil name/avatar until backfill completes; historical non-member profiles are no longer included in message composition caches.📊 Macroscope summarized 3b9bfe6. 20 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted
🗂️ Filtered Issues
No issues evaluated.