Skip to content

Account cache normalization#579

Open
ericvicenti wants to merge 8 commits into
mainfrom
account-normalization-phase-5-cleanup-hacky-authors
Open

Account cache normalization#579
ericvicenti wants to merge 8 commits into
mainfrom
account-normalization-phase-5-cleanup-hacky-authors

Conversation

@ericvicenti
Copy link
Copy Markdown
Collaborator

Summary

Eliminates blanket [ACCOUNT] cache invalidation and the bulk useHackyAuthorsSubscriptions workaround 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:

  • A profile update fired invalidateQueries([queryKeys.ACCOUNT]) — every cached account refetched, every component reading any useAccount(...) re-rendered.
  • Comment authors lived in two places: an authors dict 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.
  • For an A→B alias, 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.
  • Container components (CommentDiscussions, BlockDiscussions, FeedPage, ResourcePage) enumerated every visible author and called useHackyAuthorsSubscriptions to trigger P2P discovery in bulk, partly to avoid re-render cascades when individual discovery states changed.

Approach

profileOwner on every cached account entry is the source of truth for alias relationships. Each [ACCOUNT, uid] value carries the resolved-leaf uid as profileOwner; for non-aliased accounts, profileOwner === uid. To find every account aliased to B, we just scan [ACCOUNT, *] for entries with profileOwner === 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 whose profileOwner matches.

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, a CachedAccount type, and a populateAccountIfChanged helper.

⚠️ Reverted. A design review surfaced that the registry is redundant with the cache itself once profileOwner lives on each cache value. Kept on the stack for honest history; see the rework commit.

feat(account): register alias hops during account resolution (Phase 2)

Extracts a pure resolveAccountChain(client, uid, maxDepth=10) helper from the legacy recursive loadAccount. Adds a maxDepth=10 cap so a malformed alias cycle returns account-not-found cleanly instead of overflowing the stack — a real correctness win that survives the rework. Initially also wired the registry side effects into loadAccount and request-cache.

refactor(account): drop standalone alias registry in favor of cache-driven design (rework)

Net -314 / +24 LOC. Deletes the registry module + tests, the CachedAccount type, the populateAccountIfChanged helper, and the registerAlias calls 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:

  • HMMetadataPayloadSchema gains optional profileOwner and version fields. Documents leave them unset; account payloads populate them. Strict-mode wire validation (z.record(z.string(), HMMetadataPayloadSchema) for authors) keeps working because the new fields are optional.
  • loadAccount/loadAccounts populate profileOwner = result.uid (the resolved-leaf uid) and version (home document version). For a non-alias account, profileOwner === requested uid; for an alias, profileOwner === target uid.
  • populateAccountIfChanged(client, uid, payload) lives in query-client.ts next to invalidateQueries. 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 whose profileOwner === uid, route each through invalidateQueries so platform IPC subscriptions still fire.
  • 4 comment queryFns (useCommentsService, useDiscussionsService, useBlockDiscussionsService, useAuthoredComments) iterate result.authors and populate the cache. Wire shape is unchanged so CLI and notify keep working.
  • applyOptimisticComment mirrors 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:

  1. populateAccountIfChanged previously only skipped writes when both versions were equal, so a sparser optimistic write would stomp profileOwner/version. New rule: refuse sparser writes against versioned entries.
  2. The optimistic-comment rollback was registered unconditionally, including when the underlying populateAccountIfChanged had skipped. Now only registered when the write actually happened.
  3. loadAccount's new profileOwner/version payload 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:

  • 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.
  • New desktop main-process function appInvalidateAccountAndAliases(uid) plus a tRPC accountInvalidation subscription. Each renderer subscribes via client.accountInvalidation.subscribe(...) and runs the cache scan locally when notified. Renderer-originated calls bridge back to main via a new invalidate_account_and_aliases IPC handler so every window — including the originator (idempotent) — runs its scan.
  • app-sync.ts profile-blob handling now extracts the resource IRI uid via the new exported getProfileTargetUids helper. Critically, this uses event.data.value.resource and not event.data.value.author: per proto/activity/v1alpha/activity.proto:100-101, author is the blob signer, which differs from the profile owner for delegated profiles. LIST_ACCOUNTS invalidation is preserved alongside the new per-uid path so the library (which reads via useContactListuseAccountList, keyed on [LIST_ACCOUNTS]) keeps refreshing.
  • Renderer call sites updated: web auth.tsx EditProfileDialog, web device-link.$.tsx, desktop edit-profile-dialog.tsx.

refactor(comments): drop useHackyAuthorsSubscriptions in favor of leaf-level subscriptions (Phase 5)

-184 / +22 LOC. Now that comment queryFns populate [ACCOUNT, uid] for every author, the bulk-subscription workaround is no longer load-bearing.

  • Comment reads its own author via useAccount(authorId ?? comment.author, {subscribe: true}). The authorMetadata prop is gone; data flows from the cache (populated by Phase 3's queryFn before render).
  • CommentGroup no longer takes an authors prop — it just passes authorId to each Comment.
  • All 5 useHackyAuthorsSubscriptions call sites removed (comments.tsx x3, feed.tsx, resource-page-common.tsx), along with the allAuthorIds/authorIds memos that fed them.
  • useHackyAuthorsSubscriptions itself, plus the CommentsProvider plumbing and the desktop hook implementation file, are deleted.
  • desktop-feed.tsx and desktop-resource.tsx drop the useHackyAuthorsSubscriptions={...} prop pass-through.

The wire response is unchanged (resolvers still return {comments, authors} — decision A from the plan), so CLI cli/commands/comment.ts:57 and notify continue to read result.authors directly without modification.

What changes for users

  • Profile update on someone else → only that account's cache entry plus its aliases refetch. Previously: every cached account refetched.
  • Profile update propagates to all visible Comment instances of that author within ~1 React render tick. Previously: same, but at the cost of refetching everyone.
  • Multi-window desktop: every window's cache scans for aliases when one window receives the activity event. Previously: blanket invalidation invalidated everything in every window.
  • Cyclic alias configurations return account-not-found cleanly. Previously: stack overflow in loadAccount recursion (in theory; never reported in practice).

Test coverage

  • Shared workspace: 748 pass / 1 skipped (was 736 pre-PR; +12 net for new tests on alias chain resolution, account-cache primitives, and loadAccount profileOwner population).
  • Desktop unit: 418 pass (was 414; +4 for getProfileTargetUids).
  • Web: 55 pass / 1 skipped (no change).
  • Typecheck clean across all 9 workspaces.

New test files: frontend/packages/shared/src/__tests__/api-account.test.ts (chain resolution + alias-aware return shape) and frontend/packages/shared/src/__tests__/account-cache.test.ts (populateAccountIfChanged skip rules and invalidateAccountAndAliases cache scan).

Updated test files: frontend/apps/desktop/src/__tests__/app-sync-activity.test.ts (profile-event integration now asserts appInvalidateAccountAndAliases is 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

  • SSR loaderapps/web/app/loaders.ts:getAccount still follows aliases silently and returns sparse HMMetadataPayload (no profileOwner/version). Web has no realtime profile invalidation, so the staleness window is bounded by staleTime and refetch-on-mount. Worth a follow-up; not user-visibly broken today.
  • Wire payload shrinking — the comments resolver could stop returning authors entirely (it's now redundant on the React side), but CLI and notify still consume it. Decision A from the plan: keep the wire stable.
  • Notify and CLI — both still consume result.authors directly, which keeps working unchanged.

Test plan

  • Desktop: launch the app, navigate to a document with comments. Author avatars/names render. Edit your own profile from EditProfileDialog. Confirm: only your own account refetches in DevTools / network tab, not everyone you've ever interacted with.
  • Desktop: open two windows on the same account, one viewing a document with comments by user A, the other viewing user A's profile. From a third party, trigger a profile update for A (or wait for one to come in). Both windows should show A's new metadata; no blanket refetch storm in either.
  • Desktop: open a document where one author is an alias account. The aliased uid renders with the target's metadata as before. After a profile update on the target, the aliased entry refreshes too.
  • Desktop: activity feed renders without errors. Comments scrolled into view via LazyCommentGroup resolve names/avatars correctly.
  • Web: load any public document with comments. Authors render via SSR. After hydration, comment authors continue to render correctly.
  • Web: device-link flow completes successfully and the post-link UI shows the linked account's data.

🤖 Generated with Claude Code

ericvicenti and others added 8 commits May 8, 2026 17:23
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>
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