Skip to content

fix: unified profile - activate backfill and populate live data#1129

Closed
cameronvoell wants to merge 8 commits into
cv/profile-table-merge-repo-publisherfrom
cv/profile-table-activate
Closed

fix: unified profile - activate backfill and populate live data#1129
cameronvoell wants to merge 8 commits into
cv/profile-table-merge-repo-publisherfrom
cv/profile-table-activate

Conversation

@cameronvoell

@cameronvoell cameronvoell commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

Note

Start writing legacy member profiles to the canonical profile tables

  • Adds ProfileMemberMirror, an actor that observes the legacy memberProfile table via GRDB ValueObservation and mirrors changes into canonical profile stores using ProfileBackfill.mirror(_:).
  • Adds ProfileBackfill.mirror(_:), a public method that mirrors an in-memory set of DBMemberProfile rows into profile stores, writing only when values differ from what is already stored.
  • Updates ProfilesRepository to accept an async selfInboxIdProvider closure instead of a concrete inbox ID string, resolving and caching it lazily on first use.
  • Wires everything into MessagingService and SessionManager: mirroring starts (with an initial backfill and repository warm-up) on session start and stops on inbox teardown.
📊 Macroscope summarized 5f261c7. 5 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown

Claude finished @cameronvoell's task —— View job


Code Review Complete ✅

I've reviewed this PR and identified several critical issues that need to be addressed before merging. The macroscopeapp bot has already identified most of these, but I'm providing additional context and prioritization.


🔴 Critical Issues (Must Fix)

1. Unstructured Task Creates Race Condition on Teardown

Location: SessionManager.swift:179

The Task that calls startProfileMirroring() is never stored or cancelled. When deleteAllInboxes() runs while this task is pending, it can restart the mirror after teardown completes, causing profile data from the deleted account to pollute the next session.

Fix: Store the task handle and cancel it in deinit:

private var profileMirroringTask: Task<Void, Never>?

// In init:
profileMirroringTask = Task { [weak self] in
    guard let self, !Task.isCancelled else { return }
    await self.loadOrCreateService().startProfileMirroring()
}

// In deinit:
profileMirroringTask?.cancel()

2. Canonical Profile Tables Not Cleared on Account Deletion

Location: SessionManager.swift:552 (wipeResidualInboxRows)

The delete-all-inboxes path wipes legacy DBMemberProfile but never touches the new canonical tables (DBProfile, DBProfileAvatar, DBSelfProfile). After deletion, the next account inherits stale profile data from the deleted account.

Fix: Add to wipeResidualInboxRows():

try DBProfile.deleteAll(db)
try DBProfileAvatar.deleteAll(db)
try DBSelfProfile.deleteAll(db)

3. Inbox ID Resolution Blocks Event Processing

Location: ProfilesRepository.swift:107 and ProfileMemberMirror.swift:66

Both apply(_:) and mirror(_:) await resolveSelfInboxId() before processing any events. When the inbox isn't ready yet (which is explicitly supported per the comment at line 105), all profile events are blocked, contradicting the stated design.

Impact: On session start, if startProfileMirroring() fires before inbox-ready and the initial ValueObservation snapshot arrives, the backfill is skipped entirely. The canonical tables remain empty for the entire session.

Fix: Make resolution non-blocking:

// In apply(_:)
let selfId = cachedSelfInboxId ?? await selfInboxIdProvider()
if selfId == nil {
    // Cache miss, fire-and-forget resolution for next time
    Task { _ = await resolveSelfInboxId() }
}
if let selfId, event.inboxId == selfId {
    return  // Skip self-echo
}
// Continue processing event...

🟡 High Priority Issues

4. DM Conversations Included in Profile Publisher

Location: MessagingProfilePublishSession.swift:23

conversationIds() returns all conversations including DMs, but imageKey and sendProfileUpdate only work for groups. This causes infinite retry loops for name-only jobs and dropped avatar jobs for every DM.

Fix: Filter to groups only:

try String.fetchAll(db, sql: "SELECT id FROM conversation WHERE kind = 'group'")

5. Profile Updates Clobber memberKind and Metadata

Location: MessagingProfilePublishSession.swift:60

sendProfileUpdate only populates name and encryptedImage, leaving memberKind = .unspecified and metadata = [:]. Receivers treat these as real overwrites, so every publish silently clears the sender's agent identity and per-sender metadata like connections/timezone.

Fix: Read and preserve existing profile data:

// Before building ProfileUpdate, read existing profile from DB
let existing = try await selfProfileStore.load()
update.memberKind = existing?.memberKind ?? .unspecified
update.metadata = existing?.metadata ?? [:]
if let name {
    update.name = name
}
// ...

6. Mirror Never Deletes Stale Canonical Data

Location: ProfileBackfill.swift entire file

mirror(_:) only upserts from the current snapshot. When a conversation is deleted or a member removed from DBMemberProfile, the corresponding DBProfile/DBProfileAvatar rows remain forever. The canonical stores diverge from the source instead of mirroring it.

Fix Options:

  1. Track deletions and cascade to canonical tables
  2. Document this as acceptable during the transition period (if it is)
  3. Add a full-table reconciliation on session start

7. Self Profile Never Re-Syncs After Initial Backfill

Location: ProfileBackfill.swift:82

mirror(_:) only writes DBSelfProfile when load() == nil. Subsequent MyProfileWriter updates to DBMemberProfile (which still happen during the transition) never sync to the canonical self profile.

Fix: Merge against existing self profile:

if sawSelf {
    let existing = try await selfProfileStore.load() ?? DBSelfProfile(...)
    let merged = ProfileMerge.mergeSelfProfile(existing: existing, incoming: ...)
    if merged != existing {
        try await selfProfileStore.save(merged)
    }
}

🟢 Code Quality & Best Practices

Strengths:

  • Good use of actor isolation for ProfileMemberMirror
  • Proper task cancellation handling in start()/stop()
  • Idempotent design in ProfileBackfill.mirror(_:)
  • Clean separation of concerns between mirror and backfill

Suggestions:

  • Add comments explaining why certain inbox-id-not-ready scenarios are acceptable (or fix them per issue Privy Integration #3)
  • Consider adding metrics/logging for mirror performance (batch sizes, duration)
  • The .removeDuplicates() on line 53 is good, but consider logging when duplicates are filtered to detect excessive DB churn

🧪 Test Coverage

Good:

  • ProfileBackfillTests.swift has new tests covering the mirror(_:) method
  • Tests verify idempotency and merge behavior

Gaps:

Recommended additions:

// Test that mirror processes events when inbox ID isn't ready yet
func testMirrorProcessesEventsBeforeInboxReady()

// Test that canonical tables are cleared on account deletion
func testDeleteAllInboxesClearsCanonicaProfileTables()

// Test that self profile updates after initial backfill
func testMirrorUpdatesSelfProfileOnSubsequentRuns()

⚡ Performance Considerations

  • ValueObservation on DBMemberProfile.fetchAll(db) loads the entire table on every change. For large datasets this could be expensive. Consider if you can observe only changed rows (though GRDB's observation is already optimized).
  • The mirror(_:) method processes synchronously. If you have hundreds of members across dozens of conversations, this could block the mirror actor. Consider batching or yielding periodically.

🔒 Security Concerns

No security issues identified. Profile data is already user-controlled and properly scoped by inbox/conversation IDs.


Summary

This PR has good architectural foundations but 7 significant issues that need addressing:

  • 3 critical (race condition, missing cleanup, blocking resolution)
  • 4 high priority (DM filtering, metadata clobber, stale data, self-sync)

The most severe is the unstructured task (#1) which can cause cross-account data pollution. I recommend fixing all critical issues before merging, and at minimum documenting the high-priority issues if they're deferred to follow-up PRs.


  • Read changed files to understand implementation
  • Analyze existing bot feedback
  • Review code quality and best practices
  • Check for potential bugs and issues
  • Evaluate performance considerations
  • Assess security concerns
  • Review test coverage
  • Post comprehensive feedback

cameronvoell commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

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.
Learn more


How to use the Graphite Merge Queue

Add 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.

@cameronvoell cameronvoell changed the title start writing to new profile table fix: unified profile - activate backfill and populate live data Jul 1, 2026
/// Canonical identity source. Dormant until the cutover - nothing renders
/// from it yet.
func profilesRepository() -> ProfilesRepository {
sharedProfilesRepository

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Messaging/MessagingService.swift:197

startProfileMirroring() writes the new canonical profile tables (DBProfile, DBProfileAvatar, DBSelfProfile), but the inbox delete path never clears them — wipeResidualInboxRows() only wipes legacy tables. After deleteAllInboxes(), the deleted account's profile names and avatars remain on disk and resurface for the next session that reads from those stores. The delete path needs to wipe the new canonical tables alongside the legacy ones.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Messaging/MessagingService.swift around line 197:

`startProfileMirroring()` writes the new canonical profile tables (`DBProfile`, `DBProfileAvatar`, `DBSelfProfile`), but the inbox delete path never clears them — `wipeResidualInboxRows()` only wipes legacy tables. After `deleteAllInboxes()`, the deleted account's profile names and avatars remain on disk and resurface for the next session that reads from those stores. The delete path needs to wipe the new canonical tables alongside the legacy ones.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High

The Task at line 169 that calls loadOrCreateService().startProfileMirroring() is unstructured — its handle is never stored, so it is never cancelled during teardown. When deleteAllInboxes() runs while this task is still pending, tearDownInbox() calls existing.stopProfileMirroring() and then clears the cache, but this orphaned task can fire afterwards, call startProfileMirroring() on the same MessagingService, and restart the ProfileMemberMirror. That mirror then stays alive with the deleted session's cached selfInboxId, so later DBMemberProfile writes from a new account are mirrored into the canonical profile tables using the old account's identity. Store the task handle and cancel it in deinit alongside the other teardown cancellations.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift around line 169:

The `Task` at line 169 that calls `loadOrCreateService().startProfileMirroring()` is unstructured — its handle is never stored, so it is never cancelled during teardown. When `deleteAllInboxes()` runs while this task is still pending, `tearDownInbox()` calls `existing.stopProfileMirroring()` and then clears the cache, but this orphaned task can fire afterwards, call `startProfileMirroring()` on the same `MessagingService`, and restart the `ProfileMemberMirror`. That mirror then stays alive with the deleted session's cached `selfInboxId`, so later `DBMemberProfile` writes from a new account are mirrored into the canonical profile tables using the old account's identity. Store the task handle and cancel it in `deinit` alongside the other teardown cancellations.

return
}
var changed = false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Repository/ProfilesRepository.swift:111

apply(_:) awaits resolveSelfInboxId() before processing any inbound event. When the inbox id provider suspends (e.g. the production waitForInboxReadyResult() call), a ProfileDomainEvent for another member blocks at the self-echo check instead of being merged into DBProfile/DBProfileAvatar, contradicting the comment that unresolved self id should not stop processing. Consider resolving the self inbox id without blocking the apply path — e.g. fire-and-forget the resolution and use the cached value if available, otherwise proceed to merge the event.

Also found in 1 other location(s)

ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileMemberMirror.swift:58

ProfileMemberMirror.mirror(_:) returns immediately when resolveSelfInboxId() is still nil. startProfileMirroring() is explicitly launched early from SessionManager before inbox-ready, and GRDB ValueObservation.values(in:) only gives the initial snapshot plus later memberProfile changes. If that initial snapshot arrives before the inbox is ready and no memberProfile row changes afterward, the canonical DBProfile/DBSelfProfile tables are never backfilled at all, so this PR's new profile tables remain permanently empty/stale for that session.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfilesRepository.swift around line 111:

`apply(_:)` awaits `resolveSelfInboxId()` before processing any inbound event. When the inbox id provider suspends (e.g. the production `waitForInboxReadyResult()` call), a `ProfileDomainEvent` for another member blocks at the self-echo check instead of being merged into `DBProfile`/`DBProfileAvatar`, contradicting the comment that unresolved self id should not stop processing. Consider resolving the self inbox id without blocking the apply path — e.g. fire-and-forget the resolution and use the cached value if available, otherwise proceed to merge the event.

Also found in 1 other location(s):
- ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileMemberMirror.swift:58 -- `ProfileMemberMirror.mirror(_:)` returns immediately when `resolveSelfInboxId()` is still `nil`. `startProfileMirroring()` is explicitly launched early from `SessionManager` before inbox-ready, and GRDB `ValueObservation.values(in:)` only gives the initial snapshot plus later `memberProfile` changes. If that initial snapshot arrives before the inbox is ready and no `memberProfile` row changes afterward, the canonical `DBProfile`/`DBSelfProfile` tables are never backfilled at all, so this PR's new profile tables remain permanently empty/stale for that session.

Comment on lines +14 to +18
struct MessagingProfilePublishSession: ProfilePublishSession {
private let sessionStateManager: any SessionStateManagerProtocol
private let databaseReader: any DatabaseReader

init(sessionStateManager: any SessionStateManagerProtocol, databaseReader: any DatabaseReader) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Repository/MessagingProfilePublishSession.swift:14

conversationIds() returns ids for every row in the conversation table, including DMs, but imageKey returns nil for non-group conversations and sendProfileUpdate throws ProfilePublishSessionError.conversationNotFound for them. Because the publisher enqueues jobs for DM conversations, avatar jobs are dropped as conversationGone and name-only jobs throw and retry forever, so the publisher keeps retrying work that can never succeed for every DM. Consider filtering conversationIds() to only group conversations (e.g., SELECT id FROM conversation WHERE kind = 'group').

    func conversationIds() async throws -> [String] {
        try await databaseReader.read { db in
-            try String.fetchAll(db, sql: "SELECT id FROM conversation")
+            try String.fetchAll(db, sql: "SELECT id FROM conversation WHERE kind = 'group'")
        }
    }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Profiles/Repository/MessagingProfilePublishSession.swift around lines 14-18:

`conversationIds()` returns ids for every row in the `conversation` table, including DMs, but `imageKey` returns `nil` for non-group conversations and `sendProfileUpdate` throws `ProfilePublishSessionError.conversationNotFound` for them. Because the publisher enqueues jobs for DM conversations, avatar jobs are dropped as `conversationGone` and name-only jobs throw and retry forever, so the publisher keeps retrying work that can never succeed for every DM. Consider filtering `conversationIds()` to only group conversations (e.g., `SELECT id FROM conversation WHERE kind = 'group'`).

contentType: "application/octet-stream",
acl: "public-read"
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Repository/MessagingProfilePublishSession.swift:51

sendProfileUpdate builds a ProfileUpdate with only name and encryptedImage, leaving memberKind at .unspecified and metadata empty. Inbound apply paths treat .unspecified as a real overwrite and empty metadata as nil, so every publish from this session silently clears the sender's agent/template identity and per-sender metadata like connections/timezone for peers. Populate memberKind and metadata from the sender's existing profile before sending, or default memberKind to a no-op value the apply path skips.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Profiles/Repository/MessagingProfilePublishSession.swift around line 51:

`sendProfileUpdate` builds a `ProfileUpdate` with only `name` and `encryptedImage`, leaving `memberKind` at `.unspecified` and `metadata` empty. Inbound apply paths treat `.unspecified` as a real overwrite and empty metadata as `nil`, so every publish from this session silently clears the sender's agent/template identity and per-sender metadata like `connections`/`timezone` for peers. Populate `memberKind` and `metadata` from the sender's existing profile before sending, or default `memberKind` to a no-op value the apply path skips.

@graphite-app graphite-app Bot force-pushed the cv/profile-table-merge-repo-publisher branch from a6907f7 to 7969469 Compare July 1, 2026 20:07
@graphite-app graphite-app Bot force-pushed the cv/profile-table-activate branch from 69b95b5 to a9adc0d Compare July 1, 2026 20:07

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

ProfileBackfill.mirror(_:) only upserts identities and avatars from the current DBMemberProfile snapshot and never deletes canonical rows for members or conversations that were removed from that table. When a conversation is deleted (e.g., via DBMemberProfile.filter(...).deleteAll(db) in SessionStateMachine/UnusedConversationCache) or the legacy table is cleared on logout, the corresponding DBProfile and DBProfileAvatar rows remain in the canonical stores indefinitely. Because this PR re-runs mirror(_:) on every memberProfile change to keep the canonical stores in sync, they will return stale identities and avatars after deletions instead of matching the live legacy data. Consider tracking removed legacy rows and deleting their canonical counterparts in mirror(_:), or documenting why stale canonical rows are acceptable during the transition.

Also found in 1 other location(s)

ConvosCore/Sources/ConvosCore/Messaging/MessagingService.swift:213

Starting profileMemberMirror here exposes a data-sync bug: the mirror only replays existing DBMemberProfile rows through ProfileBackfill.mirror(rows), but that code never deletes canonical DBProfileAvatar/DBSelfProfile data when source rows disappear. If a conversation is deleted or a legacy profile row is removed, the canonical profile tables keep the old avatar/self data forever, so the new profile store diverges from the live memberProfile state instead of mirroring it.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileBackfill.swift around line 1:

`ProfileBackfill.mirror(_:)` only upserts identities and avatars from the current `DBMemberProfile` snapshot and never deletes canonical rows for members or conversations that were removed from that table. When a conversation is deleted (e.g., via `DBMemberProfile.filter(...).deleteAll(db)` in `SessionStateMachine`/`UnusedConversationCache`) or the legacy table is cleared on logout, the corresponding `DBProfile` and `DBProfileAvatar` rows remain in the canonical stores indefinitely. Because this PR re-runs `mirror(_:)` on every `memberProfile` change to keep the canonical stores in sync, they will return stale identities and avatars after deletions instead of matching the live legacy data. Consider tracking removed legacy rows and deleting their canonical counterparts in `mirror(_:)`, or documenting why stale canonical rows are acceptable during the transition.

Also found in 1 other location(s):
- ConvosCore/Sources/ConvosCore/Messaging/MessagingService.swift:213 -- Starting `profileMemberMirror` here exposes a data-sync bug: the mirror only replays existing `DBMemberProfile` rows through `ProfileBackfill.mirror(rows)`, but that code never deletes canonical `DBProfileAvatar`/`DBSelfProfile` data when source rows disappear. If a conversation is deleted or a legacy profile row is removed, the canonical profile tables keep the old avatar/self data forever, so the new profile store diverges from the live `memberProfile` state instead of mirroring it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

incoming: IncomingIdentity(name: row.name, memberKind: row.memberKind, metadata: row.metadata),

mirror(_:) only seeds DBSelfProfile when selfProfileStore.load() == nil, so the canonical self profile is never refreshed after the first backfill even when mirror is re-run on later memberProfile changes. Ongoing MyProfileWriter writes still update per-conversation DBMemberProfile rows, not DBSelfProfile, so the canonical self profile stays stale while ProfileMemberMirror is supposed to keep the canonical stores in sync. Consider merging the self row against the existing DBSelfProfile (at the same .contact/floor precedence used for other identities) so later legacy writes fill blanks and real events still supersede, instead of skipping the write once the slot exists.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileBackfill.swift around line 71:

`mirror(_:)` only seeds `DBSelfProfile` when `selfProfileStore.load() == nil`, so the canonical self profile is never refreshed after the first backfill even when `mirror` is re-run on later `memberProfile` changes. Ongoing `MyProfileWriter` writes still update per-conversation `DBMemberProfile` rows, not `DBSelfProfile`, so the canonical self profile stays stale while `ProfileMemberMirror` is supposed to keep the canonical stores in sync. Consider merging the self row against the existing `DBSelfProfile` (at the same `.contact`/floor precedence used for other identities) so later legacy writes fill blanks and real events still supersede, instead of skipping the write once the slot exists.

@cameronvoell cameronvoell force-pushed the cv/profile-table-merge-repo-publisher branch from 7969469 to 7517772 Compare July 2, 2026 18:47
@cameronvoell cameronvoell force-pushed the cv/profile-table-activate branch from a9adc0d to 1cc4b0f Compare July 2, 2026 18:47
@cameronvoell

Copy link
Copy Markdown
Contributor Author

folded into #1126

@cameronvoell cameronvoell deleted the cv/profile-table-activate branch July 2, 2026 19:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant