chore(release): v1.28.46 — server performance on large instances

Deferred perf fixes from the campaign's verified perf-correctness audit,
correctness-first (each with an output-equivalence test):

- comprehensive-generate fan-out: replaced per-type 400d findMany+JS-average
  with DB prisma.aggregate(_avg) over the exact half-open windows the JS
  reducer used (current 30d, lastMonth (60d,30d], lastYear (395d,365d]), same
  rounding + null-on-empty, pLimit(4). SLEEP_DURATION stays on the raw path
  (per-night reconstruction dedup). Byte-equality test vs the old algorithm.
- boot-discovery: replaced Prisma in-memory distinct with DB SELECT DISTINCT
   in the 4 backfill discovery scans + migration 0243 (7 additive
  partial indexes matching each predicate). Integration test asserts identical
  discovered set against real Postgres.
- ChatBubble wrapped in React.memo with a comparator (onRegenerate compared by
  presence — the thread rebuilds it each render), so only the streaming bubble
  re-renders per token.
- doctor-report mood read narrowed to {score, tags} (the only fields the
  collector uses); the 730d main measurement read left as-is — a blind bound
  would corrupt clinical stats (documented deferred follow-up).

Migration 0243 additive-only.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 06:47:36 +02:00
1 parent a59a5d75e1
commit 656e96f72b
16 files changed
+812 -216

No files matched your search

@@ -0,0 +1,89 @@
/**
* v1.28.46 perf (M3) — ChatBubble is wrapped in React.memo so a streamed
* token (which grows the live turn's content and re-runs the thread's
* messages.map) does not re-render every settled bubble. These tests pin the
* memo comparator contract:
* - a per-render `onRegenerate` closure identity change alone does NOT force
* a re-render (the thread rebuilds that closure every render),
* - a `content` or `streaming` change DOES,
* - every other render-affecting prop is compared,
* and that the exported component is actually memoized.
*/
import { describe, it, expect } from "vitest";
// Importing the module pulls the bubble's hook deps; stub them so the import
// is side-effect free (mirrors the sibling coach-charts harness).
import { vi } from "vitest";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: null, isAuthenticated: true, isLoading: false }),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: undefined }),
useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }),
}));
import { ChatBubble, areChatBubblePropsEqual } from "../chat-bubble";
type Props = Parameters<typeof areChatBubblePropsEqual>[0];
function baseProps(overrides: Partial<Props> = {}): Props {
return {
role: "assistant",
content: "hello",
metricSource: null,
providerType: "openai",
messageId: "m1",
createdAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
describe("areChatBubblePropsEqual", () => {
it("treats a fresh onRegenerate closure as equal (skips re-render)", () => {
const prev = baseProps({ onRegenerate: () => {} });
const next = baseProps({ onRegenerate: () => {} });
expect(areChatBubblePropsEqual(prev, next)).toBe(true);
});
it("re-renders when the streamed content changes", () => {
const prev = baseProps({ content: "hel", streaming: true });
const next = baseProps({ content: "hell", streaming: true });
expect(areChatBubblePropsEqual(prev, next)).toBe(false);
});
it("re-renders when the streaming flag flips", () => {
const prev = baseProps({ content: "done", streaming: true });
const next = baseProps({ content: "done", streaming: false });
expect(areChatBubblePropsEqual(prev, next)).toBe(false);
});
it("re-renders when onRegenerate presence changes (undefined -> defined)", () => {
const prev = baseProps({ onRegenerate: undefined });
const next = baseProps({ onRegenerate: () => {} });
expect(areChatBubblePropsEqual(prev, next)).toBe(false);
});
it("re-renders when a grounding / identity prop changes by reference", () => {
const prev = baseProps({ metricSource: null });
const next = baseProps({ metricSource: { metrics: ["weight"] } as never });
expect(areChatBubblePropsEqual(prev, next)).toBe(false);
expect(
areChatBubblePropsEqual(
baseProps({ messageId: "m1" }),
baseProps({ messageId: "m2" }),
),
).toBe(false);
});
it("is stable for identical settled props", () => {
expect(areChatBubblePropsEqual(baseProps(), baseProps())).toBe(true);
});
});
describe("ChatBubble memoization", () => {
it("is wrapped in React.memo", () => {
const typeTag = (ChatBubble as unknown as { $$typeof?: symbol }).$$typeof;
expect(String(typeTag)).toContain("memo");
});
});
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { memo, useCallback, useEffect, useId, useRef, useState } from "react";
import Link from "next/link";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -409,7 +409,49 @@ interface ChatBubbleProps {
onRegenerate?: () => void;
}
export function ChatBubble({
/**
* v1.28.46 perf (M3) — memo comparator for `ChatBubble`. The Coach thread
* re-renders on every streamed token (the live turn's content grows), which
* re-runs `messages.map` and re-creates every persisted bubble. Without memo
* each settled bubble re-runs `selectCoachChartTokens` + provenance work per
* token; on a long thread that is tokens × messages renders and drops frames.
*
* Every prop that changes the bubble's output is compared by value/reference.
* The ONE prop that is not stable across renders is `onRegenerate`: the thread
* builds a fresh `() => onRegenerate(precedingUserContent)` closure per render
* for each assistant message (message-thread.tsx), so a naive shallow memo
* would never skip. Its identity does not affect rendering (the closure is only
* invoked on click, and captures the same preceding user text on a settled
* thread), so it is compared by PRESENCE, not identity. Result: only the
* streaming bubble (whose `content`/`streaming` actually change) re-renders.
*
* Exported so the contract is unit-testable without standing up the thread.
*/
export function areChatBubblePropsEqual(
prev: ChatBubbleProps,
next: ChatBubbleProps,
): boolean {
return (
prev.role === next.role &&
prev.content === next.content &&
prev.streaming === next.streaming &&
prev.inProgress === next.inProgress &&
prev.errorCode === next.errorCode &&
prev.providerType === next.providerType &&
prev.messageId === next.messageId &&
prev.tokensUsed === next.tokensUsed &&
prev.model === next.model &&
prev.createdAt === next.createdAt &&
prev.metricSource === next.metricSource &&
prev.suggestion === next.suggestion &&
prev.suggestedAction === next.suggestedAction &&
prev.usage === next.usage &&
// onRegenerate is a per-render closure — compare only whether it is present.
(prev.onRegenerate === undefined) === (next.onRegenerate === undefined)
);
}
function ChatBubbleImpl({
role,
content,
metricSource,
@@ -809,6 +851,15 @@ export function ChatBubble({
);
}
/**
* v1.28.46 perf (M3) — memoized bubble. Skips re-render for every settled
* bubble while the streaming turn grows token-by-token; the streaming bubble
* (changed `content`/`streaming`) is the only one that re-renders. `displayName`
* is set so the name survives the memo wrapper in React DevTools.
*/
export const ChatBubble = memo(ChatBubbleImpl, areChatBubblePropsEqual);
ChatBubble.displayName = "ChatBubble";
/**
* v1.16.8 — per-message remember control under the user bubble.
*