Account cache normalization#579
Open
ericvicenti wants to merge 8 commits into
Open
Conversation
The discovery-side fast-path that backed `priority: 'high'` was deleted in f1bfe66 ("Rework frontend syncing"); the renderer-side type field, key-suffix bookkeeping, hook plumbing, and call site were left behind and now do nothing functional. Rip the rest out. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of the account-cache normalization plan in notes/04-account-normalization-implementation-plan.md. Pure additions — no consumers wired up yet, so behavior is unchanged. - alias-registry tracks target → Set<source> so a profile change on one account can fan out invalidations to every account that aliases to it. Includes a cycle-safe collectAliasClosure helper for testability. - CachedAccount is the canonical shape that future phases will store under [ACCOUNT, uid], including a profileOwner field that distinguishes alias entries from self-owned ones. - populateAccountIfChanged is a version-aware setQueryData wrapper that skips writes when the home document version matches, avoiding spurious re-renders. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of the account-cache normalization plan. Wires alias discovery to the registry from Phase 1 so future phases can fan out targeted invalidations. Public types and consumer-facing semantics are unchanged. - Extract resolveAccountChain in api-account.ts as a pure helper that walks the alias chain and returns each hop. Has a maxDepth=10 cap so cyclic alias configurations now return account-not-found cleanly instead of overflowing the stack the way the prior recursive loadAccount could. - loadAccount now delegates to resolveAccountChain and registers each discovered hop with the alias registry. The HMAccountResult shape and id.uid === resolved-target semantics are preserved, so notify and every other consumer keep working untouched. - request-cache's per-request alias resolver picks up the same registerAlias side effect so activity-feed walks contribute to the registry too. - Switch alias-registry's Map/Set iteration to forEach to satisfy the desktop workspace's TS target, which started type-checking the file once a desktop-graph module (request-cache) imported it. Tests cover chain resolution (single, multi-hop, cycle, gRPC error, not-found) and the loadAccount alias-registry side effect, including that earlier hops are still recorded when a later hop fails. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…riven design Phase 1 added a parallel `alias-registry` Map that tracked target → sources and Phase 2 wired registerAlias side effects into loadAccount and request-cache. Design review surfaced that the registry is redundant with the React Query cache itself: once `[ACCOUNT, uid]` carries `profileOwner`, "find every account aliased to B" becomes a one-line cache scan, the cache is the single source of truth, and there is no parallel structure to keep in sync or risk of dangling entries when RQ evicts. Removing the registry now keeps the misguided machinery from spreading into Phase 3+ consumers. The next phase reintroduces `populateAccountIfChanged` and a cache-scan `invalidateAccountAndAliases` alongside the schema augmentation that actually unlocks them. What stays from Phase 2: the `resolveAccountChain` helper and its maxDepth=10 cap on alias chains — that's a real win independent of the registry, since the legacy recursive `loadAccount` would stack-overflow on cyclic configurations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lidation Phase 3 of the account-cache normalization plan. Comment query hooks now mirror author metadata into [ACCOUNT, uid] so useAccount consumers (sidebar, profile pages, document headers, etc.) see fresh data without waiting for the next account refetch. Schema: - HMMetadataPayloadSchema gains optional profileOwner and version fields. Documents leave them unset; account payloads populate them with the resolved-leaf uid and home document version respectively. Account loading: - loadAccount populates profileOwner (= resolved-leaf uid; equals self for non-alias accounts) and version on its result. loadAccounts inherits the new fields automatically since it spreads the payload. Cache primitives in query-client.ts: - populateAccountIfChanged: version-aware setQueryData wrapper that skips no-op writes (both versions present and equal). - invalidateAccountAndAliases: scans [ACCOUNT, *] entries and invalidates every uid plus every alias whose profileOwner === uid. Routes each invalidation through invalidateQueries so desktop IPC subscriptions fire normally. Comment queryFns: - useCommentsService, useDiscussionsService, useBlockDiscussionsService, and useAuthoredComments now iterate result.authors and call populateAccountIfChanged for each entry. The wire payload is unchanged (decision A from the plan): existing consumers reading authors still work, and useAccount consumers get the side-effect benefit. Optimistic comments: - applyOptimisticComment also writes the optimistic author into [ACCOUNT, uid] with a rollback closure, so cache stays consistent across the optimistic and confirmed paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Debug-pass review of phases 1-3 surfaced three real issues: 1. populateAccountIfChanged previously only skipped writes when both versions were present and equal. A caller passing a payload with no version against a versioned cache entry would overwrite the entry, stomping profileOwner/version and losing alias info. The optimistic comment mirror is the realistic source of such sparse writes. New skip rule: when the existing entry has a version, refuse to write sparser data. 2. optimistic-comment registered a rollback for the ACCOUNT cache mirror unconditionally — including when populateAccountIfChanged skipped the write. A rollback that calls setQueryData(key, prevAccount) with a prev value of undefined performs a soft-write of undefined, which adds a no-op cache mutation. Now we only push the rollback when the write actually happened. 3. loadAccount's profileOwner + version population (the actual Phase 3 payload) had no test coverage. Adds two cases: non-alias fills self uid as profileOwner with the home document version; alias fills the resolved-target uid with the target's home version, which is what the cache-scan invalidation reads. A fourth concern surfaced — multi-renderer cache divergence under IPC broadcast — is a Phase 4 design constraint, not a Phase 3 bug. Captured in memory for the next phase to address. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of the account-cache normalization plan. Replaces the blanket [ACCOUNT] invalidation with alias-aware per-uid invalidation that works correctly when desktop runs multiple renderers with diverged caches. Architecture: - New shared primitive invalidateAccountAndAliasesEverywhere(uid) runs the local cache scan immediately for instant feedback in the originating window, then fires accountInvalidationSubscriptions so other windows on the same platform can scan their own caches. On web the subscription set is empty and only the local scan runs. - Desktop main process exposes appInvalidateAccountAndAliases(uid) which fires a tRPC accountInvalidation subscription. Each renderer subscribes and runs invalidateAccountAndAliases(uid) against its own cache when notified, so renderer-specific aliases get invalidated even though only one renderer received the activity event. - Renderer-side onAccountInvalidation handler bridges to main via the new invalidate_account_and_aliases IPC, which re-broadcasts to every renderer (including the originator — idempotent). Activity feed: - app-sync.ts profile blob handling now extracts the resource IRI uid via getProfileTargetUids and routes per uid through appInvalidateAccountAndAliases. Author is intentionally NOT used: per proto/activity/v1alpha/activity.proto:100-101 it carries the blob signer, which differs from the profile owner for delegated profiles. LIST_ACCOUNTS invalidation is preserved so the library (which reads via useContactList → useAccountList) still refreshes. - getUnconditionalInvalidations dropped its [ACCOUNT] return for profile events; profile-specific account invalidation is now an out-of-band side effect rather than a queryKey return. Renderer call sites: - web auth.tsx EditProfileDialog and device-link.$.tsx switched to invalidateAccountAndAliasesEverywhere with the specific account uid. - desktop edit-profile-dialog.tsx swapped invalidateQueries(ACCOUNT, uid) for the same call (LIST_ACCOUNTS invalidation kept). Tests: - New getProfileTargetUids tests (single, query-string strip, dedupe, non-profile filtering, empty resource). - processEvents profile-event integration tests now assert appInvalidateAccountAndAliases is called with the resource uid and that the blanket [ACCOUNT] invalidation no longer fires. - edit-profile-dialog.test.tsx mock updated for the new shared export. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…f-level subscriptions
Phase 3 made every comment author land in [ACCOUNT, uid] as a side effect
of the comment query, so the bulk-subscription workaround is no longer
load-bearing. Each Comment now reads its own author via useAccount with
subscribe: true, which co-locates the P2P discovery subscription with the
component that actually renders the data and lets React Query confine
re-renders to a single Comment when its author updates instead of the
whole list.
Removed:
- The useHackyAuthorsSubscriptions hook and its CommentsProvider wiring
(frontend/packages/shared/src/comments-service-provider.tsx).
- The desktop hook implementation
(frontend/apps/desktop/src/use-hacky-authors-subscriptions.ts deleted).
- Bulk subscription call sites in CommentDiscussions / Discussions /
BlockDiscussions (comments.tsx), the activity Feed (feed.tsx), and
ResourcePage (resource-page-common.tsx) — including the now-dead
authorIds/allAuthorIds memoizations that fed them.
- The authorMetadata prop on Comment and the authors prop on
CommentGroup, plus their four call sites in comments.tsx.
- The matching useHackyAuthorsSubscriptions={...} prop pass-through on
CommentsProvider in desktop-feed.tsx and desktop-resource.tsx.
Updated:
- inline-descriptor's AuthorNameLink had a stale comment about
useHackyAuthorsSubscriptions; rewrote it to describe the
per-component subscribe path that actually triggers discovery now.
The wire payload is unchanged — the comment resolver still returns
{comments, authors} (decision A from the plan). Consumers that read
result.authors (CLI cli/commands/comment.ts, notify) continue to work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Eliminates blanket
[ACCOUNT]cache invalidation and the bulkuseHackyAuthorsSubscriptionsworkaround in favor of a cache-driven, alias-aware design. Profile updates now refresh only the accounts whose data actually changed (and any aliased entries that borrow it), and each Comment/Author renderer subscribes to its own profile rather than relying on container-level enumeration.The work is split into commits that map to the five phases of
notes/04-account-normalization-implementation-plan.md. Each commit is independently typecheck/test-clean.Why
Before this PR:
invalidateQueries([queryKeys.ACCOUNT])— every cached account refetched, every component reading anyuseAccount(...)re-rendered.authorsdict embedded in each[DOCUMENT_COMMENTS, ...]cache entry, and the canonical[ACCOUNT, uid]cache. Updating one didn't refresh the other, so stale UI was easy to produce.loadAccount("A")silently followed to B and threw away the alias relationship, so when B's profile updated nothing in the React Query cache knew that[ACCOUNT, A]also needed refreshing — hence the blanket invalidation as a safety net.useHackyAuthorsSubscriptionsto trigger P2P discovery in bulk, partly to avoid re-render cascades when individual discovery states changed.Approach
profileOwneron every cached account entry is the source of truth for alias relationships. Each[ACCOUNT, uid]value carries the resolved-leaf uid asprofileOwner; for non-aliased accounts,profileOwner === uid. To find every account aliased to B, we just scan[ACCOUNT, *]for entries withprofileOwner === B. No parallel registry, no risk of drift between cache state and alias state.Side-effect cache population in comment queryFns keeps
[ACCOUNT, uid]warm for every author the user can see. A profile update needs to invalidate exactly the entries whoseprofileOwnermatches.Per-component subscriptions replace the bulk hack. Each Comment runs
useAccount(authorId, {subscribe: true})so that when that author's data updates, only that Comment re-renders.Phase-by-phase
feat(account): add alias registry and CachedAccount cache primitives(Phase 1)Initial scaffolding: a standalone
Map<target, Set<source>>alias registry, aCachedAccounttype, and apopulateAccountIfChangedhelper.feat(account): register alias hops during account resolution(Phase 2)Extracts a pure
resolveAccountChain(client, uid, maxDepth=10)helper from the legacy recursiveloadAccount. Adds amaxDepth=10cap so a malformed alias cycle returnsaccount-not-foundcleanly instead of overflowing the stack — a real correctness win that survives the rework. Initially also wired the registry side effects intoloadAccountandrequest-cache.refactor(account): drop standalone alias registry in favor of cache-driven design(rework)Net
-314 / +24LOC. Deletes the registry module + tests, theCachedAccounttype, thepopulateAccountIfChangedhelper, and theregisterAliascalls from Phase 2. Keeps the genuinely useful pieces (chain resolution + maxDepth cap). Phase 3 reintroduces the cache primitives in their permanent shape.feat(account): normalize comment author cache and add cache-scan invalidation(Phase 3)The structural payoff:
HMMetadataPayloadSchemagains optionalprofileOwnerandversionfields. Documents leave them unset; account payloads populate them. Strict-mode wire validation (z.record(z.string(), HMMetadataPayloadSchema)forauthors) keeps working because the new fields are optional.loadAccount/loadAccountspopulateprofileOwner = result.uid(the resolved-leaf uid) andversion(home document version). For a non-alias account,profileOwner === requested uid; for an alias,profileOwner === target uid.populateAccountIfChanged(client, uid, payload)lives inquery-client.tsnext toinvalidateQueries. Skips writes when (a) the existing entry has a version and the new one doesn't (don't degrade with sparse data), or (b) versions match (no-op).invalidateAccountAndAliases(uid)does the cache scan:findAll([ACCOUNT, *]), collect every entry whoseprofileOwner === uid, route each throughinvalidateQueriesso platform IPC subscriptions still fire.useCommentsService,useDiscussionsService,useBlockDiscussionsService,useAuthoredComments) iterateresult.authorsand populate the cache. Wire shape is unchanged so CLI and notify keep working.applyOptimisticCommentmirrors the optimistic author into the account cache with rollback.fix(account): harden cache primitives against sparse-data degradation(Phase 3 debug pass)A pre-merge review surfaced three issues:
populateAccountIfChangedpreviously only skipped writes when both versions were equal, so a sparser optimistic write would stompprofileOwner/version. New rule: refuse sparser writes against versioned entries.populateAccountIfChangedhad skipped. Now only registered when the write actually happened.loadAccount's newprofileOwner/versionpayload had no test coverage. Added two cases (non-alias self-uid, alias target-uid).feat(account): targeted profile invalidation across renderers(Phase 4)The big behavior change:
invalidateAccountAndAliasesEverywhere(uid)runs the local cache scan immediately for instant feedback in the originating window, then firesaccountInvalidationSubscriptionsso other windows on the same platform can scan their own caches.appInvalidateAccountAndAliases(uid)plus a tRPCaccountInvalidationsubscription. Each renderer subscribes viaclient.accountInvalidation.subscribe(...)and runs the cache scan locally when notified. Renderer-originated calls bridge back to main via a newinvalidate_account_and_aliasesIPC handler so every window — including the originator (idempotent) — runs its scan.app-sync.tsprofile-blob handling now extracts the resource IRI uid via the new exportedgetProfileTargetUidshelper. Critically, this usesevent.data.value.resourceand notevent.data.value.author: perproto/activity/v1alpha/activity.proto:100-101,authoris the blob signer, which differs from the profile owner for delegated profiles.LIST_ACCOUNTSinvalidation is preserved alongside the new per-uid path so the library (which reads viauseContactList→useAccountList, keyed on[LIST_ACCOUNTS]) keeps refreshing.auth.tsxEditProfileDialog, webdevice-link.$.tsx, desktopedit-profile-dialog.tsx.refactor(comments): drop useHackyAuthorsSubscriptions in favor of leaf-level subscriptions(Phase 5)-184 / +22LOC. Now that comment queryFns populate[ACCOUNT, uid]for every author, the bulk-subscription workaround is no longer load-bearing.Commentreads its own author viauseAccount(authorId ?? comment.author, {subscribe: true}). TheauthorMetadataprop is gone; data flows from the cache (populated by Phase 3's queryFn before render).CommentGroupno longer takes anauthorsprop — it just passesauthorIdto each Comment.useHackyAuthorsSubscriptionscall sites removed (comments.tsxx3,feed.tsx,resource-page-common.tsx), along with theallAuthorIds/authorIdsmemos that fed them.useHackyAuthorsSubscriptionsitself, plus theCommentsProviderplumbing and the desktop hook implementation file, are deleted.desktop-feed.tsxanddesktop-resource.tsxdrop theuseHackyAuthorsSubscriptions={...}prop pass-through.The wire response is unchanged (resolvers still return
{comments, authors}— decision A from the plan), so CLIcli/commands/comment.ts:57andnotifycontinue to readresult.authorsdirectly without modification.What changes for users
account-not-foundcleanly. Previously: stack overflow inloadAccountrecursion (in theory; never reported in practice).Test coverage
loadAccountprofileOwner population).getProfileTargetUids).New test files:
frontend/packages/shared/src/__tests__/api-account.test.ts(chain resolution + alias-aware return shape) andfrontend/packages/shared/src/__tests__/account-cache.test.ts(populateAccountIfChangedskip rules andinvalidateAccountAndAliasescache scan).Updated test files:
frontend/apps/desktop/src/__tests__/app-sync-activity.test.ts(profile-event integration now assertsappInvalidateAccountAndAliasesis called per uid; blanket[ACCOUNT]invalidation is no longer expected),frontend/apps/desktop/src/__tests__/edit-profile-dialog.test.tsx(mock for the new shared export).Things I left out
apps/web/app/loaders.ts:getAccountstill follows aliases silently and returns sparseHMMetadataPayload(noprofileOwner/version). Web has no realtime profile invalidation, so the staleness window is bounded bystaleTimeand refetch-on-mount. Worth a follow-up; not user-visibly broken today.authorsentirely (it's now redundant on the React side), but CLI and notify still consume it. Decision A from the plan: keep the wire stable.result.authorsdirectly, which keeps working unchanged.Test plan
EditProfileDialog. Confirm: only your own account refetches in DevTools / network tab, not everyone you've ever interacted with.LazyCommentGroupresolve names/avatars correctly.🤖 Generated with Claude Code