From b751ebcd3e460c9128d1a4697be855e0422874f7 Mon Sep 17 00:00:00 2001 From: Zachary Lowden Date: Mon, 22 Jun 2026 10:40:14 -0500 Subject: [PATCH] revert(api): drop the /api/user/settings notif+chat-unread fan-out (SSR 9s-timeout regression) (#2699) * Revert "perf(api): SSR-seed chat.getUnreadCount to cut ~19 req/s off api-primary (#2691)" This reverts commit 996d5982b377ac4c998ac198e07f237f28a650c6. * Revert "perf(api): SSR-inject user.checkNotifications to cut ~21 req/s off api-primary (#2690)" This reverts commit 86eab5d1d2e0a3d0c9f6004d422246355d2486af. --- .../Notifications/notifications.utils.ts | 9 +-- src/pages/_app.tsx | 44 +---------- src/pages/api/user/settings.ts | 33 +------- src/providers/AppProvider.tsx | 52 ------------ src/server/controllers/chat.controller.ts | 36 +++++++-- src/server/controllers/user.controller.ts | 21 ++++- .../__tests__/chat-unread-count.test.ts | 78 ------------------ src/server/services/chat.service.ts | 38 --------- src/server/services/notification.service.ts | 25 ------ tests/preview-ssr-inject.spec.ts | 79 ------------------- 10 files changed, 50 insertions(+), 365 deletions(-) delete mode 100644 src/server/services/__tests__/chat-unread-count.test.ts diff --git a/src/components/Notifications/notifications.utils.ts b/src/components/Notifications/notifications.utils.ts index 1d50dc70fb..dfda2adc2e 100644 --- a/src/components/Notifications/notifications.utils.ts +++ b/src/components/Notifications/notifications.utils.ts @@ -9,7 +9,6 @@ import { useSignalConnection } from '~/components/Signals/SignalsProvider'; import { useCurrentUser } from '~/hooks/useCurrentUser'; import { NotificationCategory, SignalMessages } from '~/server/common/enums'; import type { GetUserNotificationsSchema } from '~/server/schema/notification.schema'; -import type { UserNotificationCounts } from '~/server/services/notification.service'; import type { NotificationGetAll, NotificationGetAllItem } from '~/types/router'; import { getDisplayName } from '~/utils/string-helpers'; import { trpc } from '~/utils/trpc'; @@ -137,10 +136,7 @@ export const useMarkReadNotification = () => { if (newCounts[key] < 0) newCounts[key] = 0; } - // newCounts is built from `...old` (UserNotificationCounts) plus a guaranteed - // `all`, then only mutated numerically — it satisfies the count shape. The - // `Record` buffer above is only to allow dynamic-key indexing. - return newCounts as UserNotificationCounts; + return newCounts; }); // Mark as read in notification feed @@ -217,8 +213,7 @@ export const useNotificationSignal = () => { (newCounts[updated.category.toLowerCase()] ?? 0) + 1; newCounts['all']++; - // See the mark-read updater: numeric buffer that satisfies the count shape. - return newCounts as UserNotificationCounts; + return newCounts; }); }, [queryClient, queryUtils] diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 0afd3bbf93..a7e9df66ee 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -55,14 +55,11 @@ import { IsClientProvider } from '~/providers/IsClientProvider'; // import { StripeSetupSuccessProvider } from '~/providers/StripeProvider'; import { ThemeProvider } from '~/providers/ThemeProvider'; import type { UserContentSettings } from '~/server/schema/user.schema'; -import type { RouterOutput } from '~/types/router'; -type ChatUnreadCount = RouterOutput['chat']['getUnreadCount']; import type { UserSettingsChat } from '~/server/schema/chat.schema'; import { resolveChatSettings } from '~/server/schema/chat.schema'; import type { FeatureAccess } from '~/server/services/feature-flags.service'; import type { TosMeta } from '~/server/services/content.service'; import type { AnnouncementsSeed } from '~/providers/announcements-seed'; -import type { UserNotificationCounts } from '~/server/services/notification.service'; import type { BrowsingSettingsAddon } from '~/shared/constants/browsing-settings-addons'; import type { ParsedCookies } from '~/shared/utils/cookies'; import { parseCookies } from '~/shared/utils/cookies'; @@ -110,15 +107,10 @@ type CustomAppProps = { tosMeta?: TosMeta; announcements?: AnnouncementsSeed; following?: number[]; - notificationCounts?: UserNotificationCounts; seed: number; settings: UserContentSettings; browsingSettingsAddons: BrowsingSettingsAddon[]; liveNow: boolean; - // SSR-seeded `chat.getUnreadCount` (logged-in only). The per-chat unread tallies - // behind the header chat badge. Optional: absent for anon and on the fail-open - // path (the client query self-heals on its own fetch). See AppProvider seed. - chatUnreadCount?: ChatUnreadCount; // SSR-seeded `chat.getUserSettings` (logged-in only) — the per-user chat // settings (mute sounds / bad-word filter / acknowledged). Derived from the // `settings.chat` field already fetched for the bootstrap; no extra I/O. See @@ -146,14 +138,12 @@ function MyApp(props: CustomAppProps) { tosMeta, announcements, following, - notificationCounts, seed = Date.now(), canIndex, hasAuthCookie, settings, browsingSettingsAddons, liveNow = false, - chatUnreadCount, chatSettings, region, domain, @@ -207,9 +197,7 @@ function MyApp(props: CustomAppProps) { tosMeta={tosMeta} announcements={announcements} following={following} - notificationCounts={notificationCounts} liveNow={liveNow} - chatUnreadCount={chatUnreadCount} chatSettings={chatSettings} region={region} domain={domain} @@ -403,8 +391,6 @@ MyApp.getInitialProps = async (appContext: AppContext) => { tosMeta?: TosMeta; announcements?: AnnouncementsSeed; following?: number[]; - chatUnreadCount?: ChatUnreadCount; - notificationCounts?: UserNotificationCounts; session: Session | null; }; let settingsBootstrap: SettingsBootstrap; @@ -457,20 +443,10 @@ MyApp.getInitialProps = async (appContext: AppContext) => { tosMeta: undefined, announcements: undefined, following: undefined, - chatUnreadCount: undefined, - notificationCounts: undefined, session: null, }; } - const { - settings, - session, - tosMeta, - announcements, - following, - chatUnreadCount, - notificationCounts, - } = settingsBootstrap; + const { settings, session, tosMeta, announcements, following } = settingsBootstrap; // Pass these via the request so we can use them in SSR. Resolve the per-user // feature flags and the global (redis-cached, identical-for-all-users) browsing // setting addons in PARALLEL — neither depends on the other and both sit on @@ -513,22 +489,6 @@ MyApp.getInitialProps = async (appContext: AppContext) => { const domain = getRequestDomainColor(request); - // NOTE: `chat.getUnreadCount` (the always-visible header chat badge, ~19 req/s - // off api-primary) is SSR-seeded too, but it is computed in the server-only - // `/api/user/settings` route above and delivered via that fetch (read off - // `settingsBootstrap` as `chatUnreadCount`). It is deliberately NOT resolved - // here: `chat.service` is server-only and importing it into this graph — even - // via a dynamic `await import` — pulls Node built-ins (`node:fs`/ - // `node:perf_hooks`) into `_app`'s client bundle and breaks `next build` (same - // class as the `content.service`/`announcement.service` carve-outs above and - // the `prom-client` note below). It rides down through pageProps to AppProvider - // and seeds the ambient query there. It CANNOT be deferred to chat-open: the - // badge is always visible, and the live-increment path in ChatSignals.ts only - // bumps an EXISTING cache entry (`produce((old) => if (!old) return old)`) — an - // unseeded badge would silently drop every incoming-message bump until the user - // opened chat. Fail open to `undefined` (the client query self-heals on its own - // fetch) — a DB blip in the route must never 500 a page render. - // SSR-inject two ambient per-bootstrap trpc results that fire on every // logged-in page load but are fully derivable from data already fetched here. // Both client queries gate on a logged-in user, so only seed for sessions. @@ -597,8 +557,6 @@ MyApp.getInitialProps = async (appContext: AppContext) => { tosMeta, announcements, following, - chatUnreadCount, - notificationCounts, seed: Date.now(), hasAuthCookie, region, diff --git a/src/pages/api/user/settings.ts b/src/pages/api/user/settings.ts index daccc07452..a67a01cddf 100644 --- a/src/pages/api/user/settings.ts +++ b/src/pages/api/user/settings.ts @@ -4,8 +4,6 @@ import { getServerAuthSession } from '~/server/auth/get-server-auth-session'; import { getTosMeta } from '~/server/services/content.service'; import { getCurrentAnnouncements } from '~/server/services/announcement.service'; import { getUserFollows } from '~/server/redis/caches'; -import { getUnreadMessagesForUser } from '~/server/services/chat.service'; -import { getUserNotificationCounts } from '~/server/services/notification.service'; import { getRequestDomainColor } from '~/server/utils/server-domain'; export default PublicEndpoint( @@ -21,8 +19,7 @@ export default PublicEndpoint( // and drop the critical settings/session payload; tosMeta (static, no user // input) can still throw to the outer catch (preserving prior behaviour). const domainColor = getRequestDomainColor(req); - const [tosMeta, announcements, following, chatUnreadCount, notificationCounts] = - await Promise.all([ + const [tosMeta, announcements, following] = await Promise.all([ // Resolve the static per-domain ToS metadata here (server-only API route) // so `_app` getInitialProps can deliver it WITHOUT importing // `content.service` — which pulls `fs/promises` into `_app`'s client-bundled @@ -50,40 +47,12 @@ export default PublicEndpoint( session?.user ? getUserFollows(session.user.id).catch(() => undefined) : Promise.resolve(undefined), - // SSR-seed the ambient, auth-gated `chat.getUnreadCount` query (~19 req/s - // on api-primary; the always-visible header chat badge). Computed here — - // NOT in `_app` getInitialProps — because `chat.service` is server-only - // and importing it into `_app` (even via a dynamic `await import`) pulls - // Node built-ins (`node:fs`/`node:perf_hooks`, via the `env/server` + - // `errorHandling` + `unfurl.js` import chain) into the client bundle and - // breaks `next build`. `getUnreadMessagesForUser` is the same fn the - // resolver runs, so the seed is byte-identical (`{ chatId, cnt }[]`, #2471 - // gotcha). Anon never fires this protected query, so seed authed-only; on - // a chat-service error fall back to undefined and let the client self-heal - // via `ChatButton`'s own live fetch. - session?.user - ? getUnreadMessagesForUser({ userId: session.user.id }).catch(() => undefined) - : Promise.resolve(undefined), - // SSR-seed the ambient, auth-gated `user.checkNotifications` query (the - // header bell unread count, fires on every logged-in bootstrap). Uses - // the SAME shared `getUserNotificationCounts` reduce the resolver uses, - // so the seed is byte-identical to a live fetch (plain { all, } - // object of numbers — no superjson Date/array divergence). Anon never - // fires this query (protectedProcedure + `enabled: !!currentUser`), so - // seed authed-only. `.catch(undefined)` so a redis/DB blip degrades to - // no-seed (client self-heals on bootstrap) and can never reject the - // Promise.all and drop the critical settings/session payload. - session?.user - ? getUserNotificationCounts({ userId: session.user.id }).catch(() => undefined) - : Promise.resolve(undefined), ]); res.status(200).json({ settings, tosMeta, announcements, following, - chatUnreadCount, - notificationCounts, session: session?.user && Object.keys(session.user).length > 0 ? session : null, }); } catch (e) { diff --git a/src/providers/AppProvider.tsx b/src/providers/AppProvider.tsx index 9907d3db9e..393f2d5d2d 100644 --- a/src/providers/AppProvider.tsx +++ b/src/providers/AppProvider.tsx @@ -9,10 +9,6 @@ import { setServerDomains } from '~/utils/sync-account'; import { trpc } from '~/utils/trpc'; import type { AnnouncementsSeed } from '~/providers/announcements-seed'; import { reviveAnnouncementsSeed } from '~/providers/announcements-seed'; -import type { RouterOutput } from '~/types/router'; - -type ChatUnreadCount = RouterOutput['chat']['getUnreadCount']; -import type { UserNotificationCounts } from '~/server/services/notification.service'; type AppProviderProps = { children: React.ReactNode; @@ -30,24 +26,11 @@ type AppProviderProps = { // followed userIds. Seeds the query directly (fixed `undefined` key) so the // ambient follow/notify buttons never fire it on bootstrap. following?: number[]; - // SSR-computed `user.checkNotifications` result (logged-in only) — the header - // bell unread count, reduced to { all, : count }. Seeds the ambient - // `useQueryNotificationsCount` query (fixed `undefined` key) so it never fires - // on bootstrap. It's a LIVE count: the existing freshness path (the - // `NotificationNew` signal + mark-read optimistic `setData`) applies ON TOP of - // the seed, so it stays current without a refetch — the seed only replaces the - // one-shot bootstrap fetch. - notificationCounts?: UserNotificationCounts; // SSR-computed `system.getLiveNow` global boolean (a single redis.get, // identical for every user). Seeds the ambient `useIsLive` query (fixed // `undefined` key) so it never fires on bootstrap. Public procedure → seeded // for everyone, no auth gate. liveNow: boolean; - // SSR-computed `chat.getUnreadCount` (logged-in only) — the per-chat unread - // tallies behind the header chat badge. Seeds the ambient query (fixed - // `undefined` key) so the badge never fires it on bootstrap. Absent for anon - // and on the fail-open path (the consumers' own live fetch takes over). - chatUnreadCount?: ChatUnreadCount; // SSR-computed `chat.getUserSettings` (logged-in only) — the per-user chat // settings (mute sounds / bad-word filter / acknowledged). Seeds the ambient // query (fixed `undefined` key) so the chat widget never fires it on @@ -108,9 +91,7 @@ export function AppProvider({ tosMeta, announcements, following, - notificationCounts, liveNow, - chatUnreadCount, chatSettings, domain, host, @@ -136,25 +117,6 @@ export function AppProvider({ initialData: following, enabled: !!following, }); - // Seed `user.checkNotifications` (the header bell unread count) from the SSR - // snapshot so `useQueryNotificationsCount` reads a primed cache and never - // fires the query on bootstrap (~21 req/s off api-primary). Shares the fixed - // `undefined` query key with that hook. It IS a live count, but freshness - // does NOT come from a poll — it comes from the `NotificationNew` signal + - // mark-read optimistic `setData`, both of which apply on top of whatever is in - // the cache (seed or fetched). The consumer hook sets `staleTime: Infinity` - // (no time-based refetch today either), so seeding here is behavior-identical: - // it replaces the single bootstrap fetch and the count self-corrects via the - // same signal/mutation path as before. Match that `staleTime: Infinity` so the - // seed counts as fresh and no self-heal fetch fires. `enabled: !!notificationCounts` - // skips the seed (and any fetch from this provider) when there's no snapshot - // (anon never fires this protectedProcedure; a failed authed snapshot falls - // back to the consumer's own live bootstrap fetch). - trpc.user.checkNotifications.useQuery(undefined, { - initialData: notificationCounts, - enabled: !!notificationCounts, - staleTime: Infinity, - }); // Seed the global `system.getLiveNow` boolean from the SSR snapshot so the // ambient `useIsLive` consumers (header logo, social links, social home // block) read a primed cache and never fire the query on bootstrap. Shares @@ -166,20 +128,6 @@ export function AppProvider({ initialData: liveNow, staleTime: 1000 * 60 * 5, }); - // Seed `chat.getUnreadCount` (the header chat badge) from the SSR snapshot so - // the badge reads a primed cache and never fires the query on bootstrap - // (~19 req/s off api-primary). Shares the fixed `undefined` query key with - // `ChatButton`'s `trpc.chat.getUnreadCount.useQuery`. Updates stay LIVE via - // `ChatSignals`' `setData` on incoming messages (same model as - // `getFollowingUsers` above — no external churn to poll for), so the global - // `staleTime: Infinity` default is correct. `enabled: !!chatUnreadCount` skips - // the seed (and any self-heal fetch) only when there's no snapshot: anon never - // fires this protected query, and a failed authed snapshot falls back to - // `ChatButton`'s own live fetch. - trpc.chat.getUnreadCount.useQuery(undefined, { - initialData: chatUnreadCount, - enabled: !!chatUnreadCount, - }); // Seed `chat.getUserSettings` (per-user chat settings) from the SSR snapshot // so the chat widget reads a primed cache and never fires the query on // bootstrap (~19 req/s off api-primary). Shares the fixed `undefined` query diff --git a/src/server/controllers/chat.controller.ts b/src/server/controllers/chat.controller.ts index 6b6ad8f0dc..cd58abdb96 100644 --- a/src/server/controllers/chat.controller.ts +++ b/src/server/controllers/chat.controller.ts @@ -21,12 +21,7 @@ import type { import { resolveChatSettings } from '~/server/schema/chat.schema'; import { latestChat, singleChatSelect } from '~/server/selectors/chat.selector'; import { profileImageSelect } from '~/server/selectors/image.selector'; -import { - createMessage, - getUnreadMessagesForUser, - maxUsersPerChat, - upsertChat, -} from '~/server/services/chat.service'; +import { createMessage, maxUsersPerChat, upsertChat } from '~/server/services/chat.service'; import { getUserSettings, setUserSetting } from '~/server/services/user.service'; import { withSignals } from '~/server/signals/wrapper'; import { @@ -116,7 +111,34 @@ export const getUnreadMessagesForUserHandler = async ({ }) => { try { const { id: userId } = ctx.user; - return await getUnreadMessagesForUser({ userId }); + + const unread = await dbRead.$queryRaw<{ chatId: number; cnt: number }[]>` + select memb."chatId" as "chatId", + count(msg.id)::integer as "cnt" + from "ChatMember" memb + left join "ChatMessage" msg + on msg."chatId" = memb."chatId" and + (msg.id > memb."lastViewedMessageId" or + memb."lastViewedMessageId" is null + ) + where memb."userId" = ${userId} + and memb.status = 'Joined' + and memb."isMuted" is false + and msg."userId" != ${userId} + group by memb."chatId" + `; + + const pending = await dbRead.$queryRaw<{ chatId: number; cnt: number }[]>` + select memb."chatId" as "chatId", + 1 as "cnt" + from "ChatMember" memb + where memb."userId" = ${userId} + and memb.status = 'Invited' + and memb."isMuted" is false + group by memb."chatId" + `; + + return [...unread, ...pending]; } catch (error) { if (error instanceof TRPCError) throw error; else throw throwDbError(error); diff --git a/src/server/controllers/user.controller.ts b/src/server/controllers/user.controller.ts index eee4a442b3..654c95ddc1 100644 --- a/src/server/controllers/user.controller.ts +++ b/src/server/controllers/user.controller.ts @@ -5,6 +5,7 @@ import { env } from '~/env/server'; import { clickhouse } from '~/server/clickhouse/client'; import { purgeCache } from '~/server/cloudflare/client'; import { constants } from '~/server/common/constants'; +import type { NotificationCategory } from '~/server/common/enums'; import { OnboardingComplete, OnboardingSteps, @@ -52,7 +53,7 @@ import type { WithClaimKey, } from '~/server/selectors/cosmetic.selector'; import { simpleUserSelect } from '~/server/selectors/user.selector'; -import { getUserNotificationCounts } from '~/server/services/notification.service'; +import { getUserNotificationCount } from '~/server/services/notification.service'; import { getResourceReviewsByUserId, getUserResourceReview, @@ -252,9 +253,21 @@ export const checkUserNotificationsHandler = async ({ ctx }: { ctx: ProtectedCon const { id } = ctx.user; try { - // Shared reduce so the SSR-seed path (`/api/user/settings`) returns a - // byte-identical object to this resolver — see getUserNotificationCounts. - return await getUserNotificationCounts({ userId: id }); + const unreadCount = await getUserNotificationCount({ + userId: id, + unread: true, + }); + + const reduced = unreadCount.reduce( + (acc, { category, count }) => { + const key = category.toLowerCase() as Lowercase; + acc[key] = Number(count); + acc['all'] += Number(count); + return acc; + }, + { all: 0 } as Record | 'all', number> + ); + return reduced; } catch (error) { if (error instanceof TRPCError) throw error; else throw throwDbError(error); diff --git a/src/server/services/__tests__/chat-unread-count.test.ts b/src/server/services/__tests__/chat-unread-count.test.ts deleted file mode 100644 index 9bac141d2f..0000000000 --- a/src/server/services/__tests__/chat-unread-count.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -// `getUnreadMessagesForUser` is the shared source for both the `chat.getUnreadCount` -// tRPC resolver AND the new `_app` SSR bootstrap seed. The seed value must be -// byte-identical to the resolver output (#2471 gotcha) or the primed client -// cache would mismatch and force the very bootstrap refetch we're cutting. -// We mock only the two DB reads and exercise the concat/shape contract. - -const h = vi.hoisted(() => ({ - queryRaw: vi.fn(), -})); - -vi.mock('~/server/db/client', () => ({ - dbRead: { $queryRaw: h.queryRaw }, - dbWrite: {}, -})); - -// chat.service transitively imports `Prisma.validator<...>()(...)` selector -// files at module-eval; `Prisma.validator` isn't present in the SSR test -// transform of `@prisma/client`, so provide a pass-through (mirrors -// model3d-visible-id-for-post.test.ts). Unblocks the import chain without -// faking any logic this test exercises. -vi.mock('@prisma/client', () => ({ - Prisma: { - validator: () => (x: unknown) => x, - sql: () => ({}), - join: () => ({}), - raw: () => ({}), - SortOrder: { asc: 'asc', desc: 'desc' }, - }, -})); -vi.mock('unfurl.js', () => ({ unfurl: vi.fn() })); -vi.mock('linkifyjs', () => ({ find: vi.fn(() => []) })); -vi.mock('~/server/signals/wrapper', () => ({ withSignals: vi.fn() })); -vi.mock('~/server/services/blocklist.service', () => ({ - throwOnBlockedLinkDomain: vi.fn(), - throwOnBlockedMessagePattern: vi.fn(), -})); -// Cut the heavy transitive import graph chat.service drags in at module-eval: -// `user.service` reaches `image.service` -> `event-engine-common/feeds` (a -// submodule outside src, unresolvable in the test transform), and -// `user-preferences.service` is unrelated to the unread-count query. Neither is -// used by `getUnreadMessagesForUser`, so stub them at the boundary. -vi.mock('~/server/services/user.service', () => ({ getUserSettings: vi.fn() })); -vi.mock('~/server/services/user-preferences.service', () => ({ - BlockedByUsers: { getCached: vi.fn() }, - BlockedUsers: { getCached: vi.fn() }, -})); - -const { getUnreadMessagesForUser } = await import('~/server/services/chat.service'); - -describe('getUnreadMessagesForUser (chat.getUnreadCount SSR-seed source)', () => { - beforeEach(() => h.queryRaw.mockReset()); - - it('returns the joined unread tallies concatenated with pending invites', async () => { - // First call = unread (Joined), second = pending (Invited). - h.queryRaw - .mockResolvedValueOnce([{ chatId: 1, cnt: 3 }]) - .mockResolvedValueOnce([{ chatId: 9, cnt: 1 }]); - - const result = await getUnreadMessagesForUser({ userId: 42 }); - - expect(h.queryRaw).toHaveBeenCalledTimes(2); - // Byte-equality contract: the seed array shape == the resolver output — - // `{ chatId, cnt }[]`, unread first then pending. A regression in either - // query's ORDER or SHAPE breaks here. - expect(result).toEqual([ - { chatId: 1, cnt: 3 }, - { chatId: 9, cnt: 1 }, - ]); - }); - - it('returns an empty array when the user has no unread or pending chats', async () => { - h.queryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([]); - const result = await getUnreadMessagesForUser({ userId: 7 }); - expect(result).toEqual([]); - }); -}); diff --git a/src/server/services/chat.service.ts b/src/server/services/chat.service.ts index 9591f215e3..3289ac279d 100644 --- a/src/server/services/chat.service.ts +++ b/src/server/services/chat.service.ts @@ -39,44 +39,6 @@ const messageLimiter = createLimiter({ refetchInterval: 60 * 60, // 1 hour window }); -/** - * Per-chat unread tallies for a user (the header chat badge source). - * - * Extracted from the `chat.getUnreadCount` controller so the SSR bootstrap seed - * (`_app.getInitialProps`) and the tRPC resolver share ONE query — the seed must - * be byte-identical to the resolver output (#2471 gotcha) or the primed cache - * would mismatch and force an immediate refetch. - */ -export async function getUnreadMessagesForUser({ userId }: { userId: number }) { - const unread = await dbRead.$queryRaw<{ chatId: number; cnt: number }[]>` - select memb."chatId" as "chatId", - count(msg.id)::integer as "cnt" - from "ChatMember" memb - left join "ChatMessage" msg - on msg."chatId" = memb."chatId" and - (msg.id > memb."lastViewedMessageId" or - memb."lastViewedMessageId" is null - ) - where memb."userId" = ${userId} - and memb.status = 'Joined' - and memb."isMuted" is false - and msg."userId" != ${userId} - group by memb."chatId" - `; - - const pending = await dbRead.$queryRaw<{ chatId: number; cnt: number }[]>` - select memb."chatId" as "chatId", - 1 as "cnt" - from "ChatMember" memb - where memb."userId" = ${userId} - and memb.status = 'Invited' - and memb."isMuted" is false - group by memb."chatId" - `; - - return [...unread, ...pending]; -} - export const upsertChat = async ({ userIds, isModerator, diff --git a/src/server/services/notification.service.ts b/src/server/services/notification.service.ts index cab6cc59ce..d032651d13 100644 --- a/src/server/services/notification.service.ts +++ b/src/server/services/notification.service.ts @@ -178,31 +178,6 @@ export async function getUserNotificationCount({ return result; } -// The reduced { all, : count } shape consumed by the header bell. -// Extracted so BOTH `user.checkNotifications` (the tRPC resolver) and the -// `_app` SSR-seed path (`/api/user/settings`) produce a byte-identical object — -// the seed must equal a live fetch or the header count would render wrong on -// first paint. Single source of truth for the reduce + key-casing. -export type UserNotificationCounts = Record | 'all', number>; - -export async function getUserNotificationCounts({ - userId, -}: { - userId: number; -}): Promise { - const unreadCount = await getUserNotificationCount({ userId, unread: true }); - - return unreadCount.reduce( - (acc, { category, count }) => { - const key = category.toLowerCase() as Lowercase; - acc[key] = Number(count); - acc['all'] += Number(count); - return acc; - }, - { all: 0 } as UserNotificationCounts - ); -} - // Per-user serialization queue. Rapid-click streams previously fanned out to // N concurrent pool.connect() acquisitions against the notif pool, which // starved it under load (46k "Connection terminated due to connection timeout" diff --git a/tests/preview-ssr-inject.spec.ts b/tests/preview-ssr-inject.spec.ts index 71fe9e5128..dec34f2528 100644 --- a/tests/preview-ssr-inject.spec.ts +++ b/tests/preview-ssr-inject.spec.ts @@ -44,7 +44,6 @@ const TOS_PROCEDURE = 'content.checkTosUpdate'; const ANNOUNCEMENTS_PROCEDURE = 'announcement.getAnnouncements'; const FOLLOWING_PROCEDURE = 'user.getFollowingUsers'; const LIVE_NOW_PROCEDURE = 'system.getLiveNow'; -const CHECK_NOTIFICATIONS_PROCEDURE = 'user.checkNotifications'; // A core page a gate-passing user lands on directly (no /login bounce). Both // procedures fire on every logged-in bootstrap regardless of which page, so @@ -472,84 +471,6 @@ test.describe('SSR-injected getLiveNow (anonymous)', () => { }); }); -/** - * Regression guard for the SSR-inject of `user.checkNotifications` (~21/s on - * api-primary) — the header notification-bell unread count. It is a - * `protectedProcedure` consumed by the ambient `useQueryNotificationsCount` - * hook, so it fires on every LOGGED-IN bootstrap (never anon → no anon block, - * same as getFollowingUsers). SSR-computed in the `/api/user/settings` - * self-fetch that `_app` already makes (so NO server-only module is pulled into - * `_app`'s client bundle) via the SAME shared `getUserNotificationCounts` reduce - * the resolver uses, then seeded in AppProvider on the fixed `undefined` key. - * - * Byte-equality: the payload is a plain `{ all, : count }` object of - * numbers — no Date/array/`undefined` field, so the JSON seed and the live - * `result.data.json` compare directly with no superjson `undefined`→`null` - * divergence. (`buzz` is `0` not absent when there are no buzz notifications: - * the resolver only emits categories the SQL GROUP BY returns, so an absent - * category is absent on BOTH the seed and the live fetch — they still `toEqual`.) - * - * FRESHNESS (the live-count requirement): this seed REPLACES the one-shot - * bootstrap fetch only — it does NOT make the count stickier. The consumer hook - * already uses `staleTime: Infinity` (no time-poll today either); the count is - * kept live by the `NotificationNew` SignalR push + the mark-read optimistic - * `setData`, both of which apply on top of the cache (seed or fetched) exactly - * as before. So "seed-then-(signal-driven)-refresh" is preserved. - */ -test.describe('SSR-injected checkNotifications (logged-in)', () => { - test.use({ storageState: storageStatePath('mod') }); - - test('checkNotifications is seeded into __NEXT_DATA__ and not fetched on bootstrap', async ({ - page, - }) => { - const trpcUrls = await collectTrpcRequests(page, AUTHED_LANDING); - - const counts = await readPageProp(page, 'notificationCounts'); - expect(counts.present, 'notificationCounts present in __NEXT_DATA__ pageProps').toBe(true); - expect( - counts.value && typeof counts.value === 'object' && !Array.isArray(counts.value), - 'notificationCounts seed is a plain object' - ).toBe(true); - // The reduced shape always carries an `all` total (the reduce seeds it to 0). - expect(typeof (counts.value as Record)?.all, 'seed.all is a number').toBe( - 'number' - ); - - const requests = trpcUrls.filter((u) => u.includes(CHECK_NOTIFICATIONS_PROCEDURE)); - expect( - requests, - `no ${CHECK_NOTIFICATIONS_PROCEDURE} tRPC request should fire on bootstrap (it is SSR-injected); saw:\n${requests.join( - '\n' - )}` - ).toHaveLength(0); - }); - - test('SSR seed byte-equals a live fetch', async ({ page }) => { - await page.goto(AUTHED_LANDING, { waitUntil: 'domcontentloaded' }); - - const seed = await readPageProp(page, 'notificationCounts'); - expect(seed.present, 'notificationCounts seed present in __NEXT_DATA__').toBe(true); - - const live = await fetchTrpcQueryJson(page, CHECK_NOTIFICATIONS_PROCEDURE); - // The shared `getUserNotificationCounts` reduce backs both the seed and the - // resolver, so they must be deep-equal. A wrong initial count is worse than - // not seeding (the bell would render stale on first paint) — this is the gate. - expect( - seed.value, - 'user.checkNotifications SSR seed must byte-equal a live resolver fetch' - ).toEqual(live); - - // Surface the totals so an all-zero run (no unread notifications) is visible - // rather than a silent trivial pass, and assert every value is a number. - const obj = (seed.value ?? {}) as Record; - // eslint-disable-next-line no-console - console.log(`[ssr-inject] checkNotifications seed: ${JSON.stringify(obj)}`); - for (const [k, v] of Object.entries(obj)) { - expect(typeof v, `checkNotifications.${k} is a number`).toBe('number'); - } - }); -}); - /** * MANUAL CHECKLIST — behaviours of this PR that are not auto-asserted above. * (Auth IS supported by this harness, but these are timing/visual/state cases