Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions crates/buzz-db/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,8 +428,10 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result<Vec<StoredEve
}

// e-tag pushdown via JSONB containment: tags @> '[["e","<hex>"]]'.
// Multiple e-tags use OR (any match). No GIN index yet — acceptable at
// current scale; add `CREATE INDEX ... USING gin(tags)` if this becomes hot.
// Multiple e-tags use OR (any match). Served by idx_events_tags_gin
// (GIN, jsonb_path_ops — migrations/0004): the channel-window aux closure
// fans this out once per retained row, which made unindexed containment
// the dominant scroll-back cost (~1.7s/page on staging).
if let Some(ref e_tags) = q.e_tags {
if !e_tags.is_empty() {
qb.push(" AND (");
Expand Down
11 changes: 10 additions & 1 deletion crates/buzz-db/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ mod tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);

assert_eq!(migrations.len(), 3);
assert_eq!(migrations.len(), 4);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
Expand Down Expand Up @@ -515,6 +515,15 @@ mod tests {
.as_str()
.contains("ALTER TABLE communities ADD COLUMN icon"));
assert!(!migrations[0].sql.as_str().contains("icon"));

// Same additive-migration rule for the e-tag containment GIN index
// (channel-window aux closure): its own version, never folded into 0001.
assert_eq!(migrations[3].version, 4);
assert!(migrations[3]
.sql
.as_str()
.contains("CREATE INDEX idx_events_tags_gin"));
assert!(!migrations[0].sql.as_str().contains("idx_events_tags_gin"));
}

#[test]
Expand Down
7 changes: 7 additions & 0 deletions desktop/src/features/agents/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ensureChannelAgentPresetInChannel,
} from "@/features/agents/channelAgents";
import { channelsQueryKey } from "@/features/channels/hooks";
import { evictUsersBatchEntries } from "@/features/profile/hooks";
import {
createManagedAgent,
deleteManagedAgent,
Expand Down Expand Up @@ -278,6 +279,12 @@ export function useUpdateManagedAgentMutation() {
// rows, channel header chips, and message author labels refresh too.
const lowerPubkey = variables.pubkey.toLowerCase();

// The users-batch delta fetch resolves from per-pubkey
// ["users-batch-entry", pubkey] entries with their own 60s freshness —
// invalidating the aggregate queries alone would just re-read the stale
// entry. Evict it first so the re-run refetches this profile.
evictUsersBatchEntries(queryClient, [lowerPubkey]);

await Promise.all([
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }),
Expand Down
85 changes: 84 additions & 1 deletion desktop/src/features/profile/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
InfiniteData,
QueryClient,
UseInfiniteQueryResult,
} from "@tanstack/react-query";
import * as React from "react";
Expand All @@ -25,6 +26,7 @@ import type {
UpdateProfileInput,
UserSearchResult,
UserSearchPage,
UserProfileSummary,
UsersBatchResponse,
} from "@/shared/api/types";
import { useIdentityQuery } from "@/shared/api/hooks";
Expand Down Expand Up @@ -267,6 +269,37 @@ export function useUserProfileQuery(pubkey?: string) {
});
}

// Per-pubkey resolution cache backing `useUsersBatchQuery`'s delta fetch.
// `summary: null` records a relay-confirmed miss so unknown pubkeys aren't
// re-requested every page. Entries older than the hook's 60s staleTime are
// treated as unresolved and refetched.
type UsersBatchEntry = {
summary: UserProfileSummary | null;
fetchedAt: number;
};

const usersBatchEntryKey = (pubkey: string) => ["users-batch-entry", pubkey];

/**
* Drop the per-pubkey delta-fetch entries so the next `useUsersBatchQuery`
* run re-fetches these profiles from the relay. Must be called anywhere a
* specific profile (or a containing `users-batch` query) is invalidated —
* otherwise the re-run resolves from the still-fresh-looking entry and
* renders the stale name/avatar for up to the entry's 60s freshness window.
* Synchronous, so callers can evict before awaiting aggregate invalidations.
*/
export function evictUsersBatchEntries(
queryClient: QueryClient,
pubkeys: string[],
) {
for (const pubkey of pubkeys) {
queryClient.removeQueries({
queryKey: usersBatchEntryKey(pubkey.toLowerCase()),
exact: true,
});
}
}

export function useUsersBatchQuery(
pubkeys: string[],
options?: {
Expand All @@ -284,7 +317,43 @@ export function useUsersBatchQuery(
const query = useQuery<UsersBatchResponse>({
enabled,
queryKey: ["users-batch", ...normalizedPubkeys],
queryFn: () => getUsersBatch(normalizedPubkeys),
// Delta fetch: scroll-back grows the author set one page at a time, and
// keying on the full sorted list means every growth re-runs the query.
// Requesting the accumulated set re-downloaded every already-resolved
// profile (kind-0 payloads embed avatars — ~800KB per scroll page on
// staging; RESEARCH/PERF_STAGING_SCROLLBACK.md). Resolve from the
// per-pubkey entry cache first and hit the network only for pubkeys not
// freshly resolved.
queryFn: async () => {
const now = Date.now();
const profiles: UsersBatchResponse["profiles"] = {};
const missing: string[] = [];
const toFetch: string[] = [];
for (const pubkey of normalizedPubkeys) {
const entry = queryClient.getQueryData<UsersBatchEntry>(
usersBatchEntryKey(pubkey),
);
if (entry && now - entry.fetchedAt < 60_000) {
if (entry.summary) profiles[pubkey] = entry.summary;
else missing.push(pubkey);
} else {
toFetch.push(pubkey);
}
}
if (toFetch.length > 0) {
const fresh = await getUsersBatch(toFetch);
for (const pubkey of toFetch) {
const summary = fresh.profiles[pubkey] ?? null;
queryClient.setQueryData<UsersBatchEntry>(
usersBatchEntryKey(pubkey),
{ summary, fetchedAt: now },
);
if (summary) profiles[pubkey] = summary;
else missing.push(pubkey);
}
}
return { profiles, missing };
},
// Loading older messages grows the pubkey set, which changes this query's
// key entirely. Without this, already-resolved authors would flash back
// to their raw pubkey while the larger batch refetches.
Expand Down Expand Up @@ -412,6 +481,20 @@ export function useUpdateProfileMutation() {
if (relayUrl && pubkey) {
void persistSelfProfile(relayUrl, pubkey, profile);
}
if (pubkey) {
// Own author labels/avatars render through the users-batch delta
// cache too — evict so the next batch run picks up the new profile
// instead of the fresh-looking stale entry, then poke the aggregates.
evictUsersBatchEntries(queryClient, [pubkey]);
void queryClient.invalidateQueries({
queryKey: ["user-profile", pubkey.toLowerCase()],
});
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "users-batch" &&
query.queryKey.includes(pubkey.toLowerCase()),
});
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: profileQueryKey });
Expand Down
Loading
Loading