mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
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 commit996d5982b3. * Revert "perf(api): SSR-inject user.checkNotifications to cut ~21 req/s off api-primary (#2690)" This reverts commit86eab5d1d2.
This commit is contained in:
@@ -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<string, number>` 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]
|
||||
|
||||
+1
-43
@@ -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,
|
||||
|
||||
@@ -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, <cat> }
|
||||
// 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) {
|
||||
|
||||
@@ -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, <category>: 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<NotificationCategory>;
|
||||
acc[key] = Number(count);
|
||||
acc['all'] += Number(count);
|
||||
return acc;
|
||||
},
|
||||
{ all: 0 } as Record<Lowercase<NotificationCategory> | 'all', number>
|
||||
);
|
||||
return reduced;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
else throw throwDbError(error);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -178,31 +178,6 @@ export async function getUserNotificationCount({
|
||||
return result;
|
||||
}
|
||||
|
||||
// The reduced { all, <category>: 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<Lowercase<NotificationCategory> | 'all', number>;
|
||||
|
||||
export async function getUserNotificationCounts({
|
||||
userId,
|
||||
}: {
|
||||
userId: number;
|
||||
}): Promise<UserNotificationCounts> {
|
||||
const unreadCount = await getUserNotificationCount({ userId, unread: true });
|
||||
|
||||
return unreadCount.reduce(
|
||||
(acc, { category, count }) => {
|
||||
const key = category.toLowerCase() as Lowercase<NotificationCategory>;
|
||||
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"
|
||||
|
||||
@@ -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, <category>: 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<string, unknown>)?.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<string, unknown>;
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user