diff --git a/next.config.ts b/next.config.ts index f802455..3a8b106 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + cacheComponents: true, reactCompiler: true, serverExternalPackages: ["pg", "@neondatabase/serverless", "postgres"], allowedDevOrigins: [ diff --git a/src/app/login/page.test.ts b/src/app/login/page.test.ts index 3d10dd5..906bc21 100644 --- a/src/app/login/page.test.ts +++ b/src/app/login/page.test.ts @@ -1,8 +1,12 @@ // @vitest-environment jsdom import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import LoginPage from "./page"; +vi.mock("next/server", () => ({ + connection: async () => {}, +})) + +import { LoginContent } from "./page"; describe("LoginPage", () => { const originalDashboardOrigin = process.env.DASHBOARD_ORIGIN; @@ -30,7 +34,7 @@ describe("LoginPage", () => { }); it("links directly to Dashboard login with the Notebook callback URL", async () => { - render(await LoginPage()); + render(await LoginContent()); const link = screen.getByRole("link", { name: "Sign in" }); @@ -41,7 +45,7 @@ describe("LoginPage", () => { }); it("uses account language instead of implementation details", async () => { - const { container } = render(await LoginPage()); + const { container } = render(await LoginContent()); expect(screen.getByRole("link", { name: "Sign in" })).toBeTruthy(); expect(screen.getByText("Use your Knowhere account to continue.")).toBeTruthy(); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 6ef9b51..c802af3 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,14 +1,21 @@ +import { Suspense } from "react" import Link from "next/link"; import { NotebookLogoMark } from "@/components/notebook-logo-mark"; import { headers } from "next/headers"; import { Card, CardContent } from "@/components/ui/card"; import { authURLs } from "@/infrastructure/auth/urls"; +import { connection } from "next/server"; -/** - * Login gate preview for the MVP shell. The real auth redirect is handled by - * server-side guards; this page keeps direct `/login` visits user-friendly. - */ -export default async function LoginPage() { +export default function LoginPage() { + return ( + + + + ) +} + +export async function LoginContent() { + await connection() const notebookPublicURL = process.env.NOTEBOOK_PUBLIC_URL ?? authURLs.resolveNotebookPublicURLFromHeaders(await headers()); diff --git a/src/app/page.test.ts b/src/app/page.test.ts index 3ce551b..9ec6aa1 100644 --- a/src/app/page.test.ts +++ b/src/app/page.test.ts @@ -1,6 +1,10 @@ import React from "react" import { describe, expect, it, vi } from "vitest" +vi.mock("next/server", () => ({ + connection: async () => {}, +})) + const mocks = vi.hoisted(() => ({ loadWorkspaceShellInitialState: vi.fn(), })) @@ -9,7 +13,7 @@ vi.mock("@/domains/workspace/initial-state", () => ({ loadWorkspaceShellInitialState: mocks.loadWorkspaceShellInitialState, })) -import Home from "./page" +import { HomeContent } from "./page" describe("Home", () => { it("renders the workspace shell from the API-backed initial state", async () => { @@ -20,7 +24,7 @@ describe("Home", () => { chatMessages: [], }) - const element = await Home() + const element = await HomeContent() expect(React.isValidElement(element)).toBe(true) expect(mocks.loadWorkspaceShellInitialState).toHaveBeenCalledOnce() diff --git a/src/app/page.tsx b/src/app/page.tsx index 7a27368..b2efa13 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,8 +1,17 @@ +import { Suspense } from "react" import { WorkspaceShell } from "@/components/workspace-shell" import { loadWorkspaceShellInitialState } from "@/domains/workspace/initial-state" +import { connection } from "next/server" -export const dynamic = "force-dynamic" +export default function Home() { + return ( + + + + ) +} -export default async function Home() { +export async function HomeContent() { + await connection() return } diff --git a/src/components/workspace-selected-chunks.ts b/src/components/workspace-selected-chunks.ts index ddf7e63..e1f9efb 100644 --- a/src/components/workspace-selected-chunks.ts +++ b/src/components/workspace-selected-chunks.ts @@ -59,6 +59,13 @@ export function useWorkspaceSelectedChunks({ keepPreviousData: false, }, ) + const resolvedPrefetchedChunks = useMemo( + () => + prefetchedSelectedChunks + ? resolveChunkConnectionTargets(prefetchedSelectedChunks) + : undefined, + [prefetchedSelectedChunks], + ) const pagedSelectedChunks = useMemo( () => resolveChunkConnectionTargets( @@ -67,7 +74,7 @@ export function useWorkspaceSelectedChunks({ [selectedChunkPages], ) const selectedChunks = selectedSourceId - ? (prefetchedSelectedChunks ?? pagedSelectedChunks) + ? (resolvedPrefetchedChunks ?? pagedSelectedChunks) : [] const hasMoreSelectedChunks = !prefetchedSelectedChunks && diff --git a/src/domains/demo/view.ts b/src/domains/demo/view.ts index 861241e..719cdc7 100644 --- a/src/domains/demo/view.ts +++ b/src/domains/demo/view.ts @@ -53,7 +53,7 @@ function toChatMessages(catalog: DemoCatalog): ChatMessageView[] { ? { description: citation.description } : {}), source: { - documentId: citation.source.documentId, + documentId: citation.canonicalDocumentId, sourceFileName: citation.source.sourceFileName, sectionPath: citation.source.sectionPath, }, diff --git a/src/domains/workspace/initial-state.ts b/src/domains/workspace/initial-state.ts index d11e1fb..5d4ee9a 100644 --- a/src/domains/workspace/initial-state.ts +++ b/src/domains/workspace/initial-state.ts @@ -1,6 +1,5 @@ import "server-only" -import { unstable_cache } from "next/cache" import { Effect } from "effect" import type { ChatMessageView } from "@/domains/chat/types" @@ -50,37 +49,33 @@ type WorkspaceShellInitialState = { } } -const getCachedDemoChunksForSource = (demoSourceId: string) => - unstable_cache( - async (): Promise => { - const chunkPage = await knowhereDemoApi.fetchChunkPage({ - demoSourceId, - page: 1, - pageSize: 100, - }) - const sourceView = demoView.toSourceView({ - demoSourceId: chunkPage.demoSourceId, - canonicalDocumentId: chunkPage.canonicalDocumentId, - title: chunkPage.title, - mimeType: chunkPage.mimeType, - sizeBytes: 0, - status: "ready", - chunkCount: chunkPage.pagination.total, - originalFile: { - url: "", - mimeType: "", - sizeBytes: 0, - canDownload: false, - }, - examples: [], - }) - return chunkPage.chunks.map((chunk) => - demoView.toParsedChunkView(sourceView, chunk), - ) - }, - ["demo-chunks", demoSourceId], - { revalidate: false }, - )() +// Aligned with workspaceClientConfig.sourceChunkPageSize so the SSR +// prefetch doesn't overlap with the first client-side page request. +const DEMO_CHUNK_PREFETCH_PAGE_SIZE = 50 + +async function getDemoChunksForSource( + demoSourceId: string, +): Promise { + const chunkPage = await knowhereDemoApi.fetchChunkPage({ + demoSourceId, + page: 1, + pageSize: DEMO_CHUNK_PREFETCH_PAGE_SIZE, + }) + // Only title and documentId are consumed by toParsedChunkView, + // so a minimal SourceView is sufficient. + const sourceView: SourceView = { + id: chunkPage.demoSourceId, + kind: "demo", + demoSourceId: chunkPage.demoSourceId, + title: chunkPage.title, + mimeType: chunkPage.mimeType, + status: "ready", + documentId: chunkPage.canonicalDocumentId, + } + return chunkPage.chunks.map((chunk) => + demoView.toParsedChunkView(sourceView, chunk), + ) +} type WorkspaceShellInitialStateClient = Parameters[1] @@ -161,7 +156,7 @@ export const loadWorkspaceShellInitialStateEffect = ( if (firstDemoSource) { const chunks = yield* Effect.catchAll( Effect.tryPromise(() => - getCachedDemoChunksForSource(firstDemoSource.demoSourceId), + getDemoChunksForSource(firstDemoSource.demoSourceId), ), () => Effect.succeed([] as ParsedChunkView[]), ) diff --git a/src/infrastructure/auth/index.test.ts b/src/infrastructure/auth/index.test.ts index af43591..edd92ac 100644 --- a/src/infrastructure/auth/index.test.ts +++ b/src/infrastructure/auth/index.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +vi.mock("next/cache", () => ({ + cacheLife: () => {}, + cacheTag: () => {}, +})) + /** * Tests for the auth module. * diff --git a/src/infrastructure/auth/index.ts b/src/infrastructure/auth/index.ts index 95a65b0..6674e74 100644 --- a/src/infrastructure/auth/index.ts +++ b/src/infrastructure/auth/index.ts @@ -2,6 +2,7 @@ import "server-only" import { cookies, headers } from "next/headers" import { redirect } from "next/navigation" +import { cacheLife, cacheTag } from "next/cache" import { Context, Effect, Either, Layer, Schedule, Schema } from "effect" import { FetchHttpClient, @@ -57,74 +58,77 @@ const DASHBOARD_SESSION_TIMEOUT_MS = 3_000 // ---- Effect implementation ------------------------------------------------ +const callGetCurrentUser = (cookieHeader: string) => + Effect.gen(function* () { + const origin = process.env.DASHBOARD_ORIGIN + if (!origin) { + return yield* Effect.die( + new Error( + "DASHBOARD_ORIGIN is required. Set it to the Dashboard origin " + + "(see .env.local.example).", + ), + ) + } + + const http = yield* HttpClient.HttpClient + const url = `${origin}/api/orpc/users/getCurrentUser` + return yield* HttpClientRequest.post(url).pipe( + HttpClientRequest.setHeader("cookie", cookieHeader), + setEmptyJsonBody, + http.execute, + Effect.flatMap((response) => + Effect.gen(function* () { + const status = response.status + + if (status < 200 || status >= 300) { + const rawText = yield* Effect.either(response.text) + logger.warn( + "dashboard: POST /api/orpc/users/getCurrentUser -> non-2xx", + { status, body: Either.getOrElse(rawText, () => "").slice(0, 1000) }, + ) + return null + } + + const parsed = yield* Effect.either(response.json) + if (Either.isLeft(parsed)) { + logger.warn( + "dashboard: POST /api/orpc/users/getCurrentUser -> invalid JSON", + { status, error: String(parsed.left) }, + ) + return null + } + + const result = Schema.decodeUnknownEither(oRPCEnvelope)(parsed.right) + if (Either.isLeft(result)) { + logger.warn( + "dashboard: POST /api/orpc/users/getCurrentUser -> schema mismatch", + { status, body: formatUnknownForLog(parsed.right).slice(0, 1000) }, + ) + return null + } + + return result.right.json.user + }), + ), + Effect.timeout(DASHBOARD_SESSION_TIMEOUT_MS), + Effect.catchAll((err) => { + logger.warn( + "dashboard: POST /api/orpc/users/getCurrentUser -> failed", + { error: String(err) }, + ) + return Effect.succeed(null) + }), + ) + }) + export const getCurrentUserEffect = Effect.gen(function* () { const developmentUser = knowhereApiKeyOverride.getDevelopmentUser() if (developmentUser) return developmentUser - const origin = process.env.DASHBOARD_ORIGIN - if (!origin) { - return yield* Effect.die( - new Error( - "DASHBOARD_ORIGIN is required. Set it to the Dashboard origin " + - "(see .env.local.example).", - ), - ) - } - const cookieHeader = (yield* Effect.promise(() => headers())).get("cookie") ?? "" if (cookieHeader.length === 0) return null - const http = yield* HttpClient.HttpClient - const url = `${origin}/api/orpc/users/getCurrentUser` - const body = yield* HttpClientRequest.post(url).pipe( - HttpClientRequest.setHeader("cookie", cookieHeader), - setEmptyJsonBody, - http.execute, - Effect.flatMap((response) => - Effect.gen(function* () { - const status = response.status - - if (status < 200 || status >= 300) { - const rawText = yield* Effect.either(response.text) - logger.warn( - "dashboard: POST /api/orpc/users/getCurrentUser -> non-2xx", - { status, body: Either.getOrElse(rawText, () => "").slice(0, 1000) }, - ) - return null - } - - const parsed = yield* Effect.either(response.json) - if (Either.isLeft(parsed)) { - logger.warn( - "dashboard: POST /api/orpc/users/getCurrentUser -> invalid JSON", - { status, error: String(parsed.left) }, - ) - return null - } - - const result = Schema.decodeUnknownEither(oRPCEnvelope)(parsed.right) - if (Either.isLeft(result)) { - logger.warn( - "dashboard: POST /api/orpc/users/getCurrentUser -> schema mismatch", - { status, body: formatUnknownForLog(parsed.right).slice(0, 1000) }, - ) - return null - } - - return result.right.json.user - }), - ), - Effect.timeout(DASHBOARD_SESSION_TIMEOUT_MS), - Effect.catchAll((err) => { - logger.warn( - "dashboard: POST /api/orpc/users/getCurrentUser -> failed", - { error: String(err) }, - ) - return Effect.succeed(null) - }), - ) - - return body + return yield* callGetCurrentUser(cookieHeader) }) // ---- Auth Service --------------------------------------------------------- @@ -150,6 +154,20 @@ export const authLayer = Layer.effect( // ---- Public API (Promise-based, for Next.js compatibility) ---------------- +async function getCurrentUserCached( + cookieHeader: string, +): Promise { + "use cache" + cacheLife("minutes") + cacheTag("current-user") + + return Effect.runPromise( + callGetCurrentUser(cookieHeader).pipe( + Effect.provide(FetchHttpClient.layer), + ), + ) +} + export async function getCurrentUser(): Promise { const developmentUser = knowhereApiKeyOverride.getDevelopmentUser() if (developmentUser) { @@ -166,9 +184,7 @@ export async function getCurrentUser(): Promise { } const start = Date.now() - const user = await Effect.runPromise( - getCurrentUserEffect.pipe(Effect.provide(FetchHttpClient.layer)), - ) + const user = await getCurrentUserCached(cookieHeader) if (user === null) { logger.info("dashboard: POST /api/orpc/users/getCurrentUser -> no valid session", { diff --git a/src/integrations/dashboard/api-key-service.test.ts b/src/integrations/dashboard/api-key-service.test.ts index 30db09b..99fb88e 100644 --- a/src/integrations/dashboard/api-key-service.test.ts +++ b/src/integrations/dashboard/api-key-service.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest" +vi.mock("next/cache", () => ({ + cacheLife: () => {}, + cacheTag: () => {}, +})) + import { ensureApiKeyForWorkspace, fetchKnowhereJwt, diff --git a/src/integrations/dashboard/api-key-service.ts b/src/integrations/dashboard/api-key-service.ts index 1214cd1..410a06a 100644 --- a/src/integrations/dashboard/api-key-service.ts +++ b/src/integrations/dashboard/api-key-service.ts @@ -1,6 +1,7 @@ import "server-only" import { Effect, Either, Schema } from "effect" +import { cacheLife, cacheTag } from "next/cache" import { FetchHttpClient, HttpClient, @@ -87,6 +88,25 @@ export const fetchKnowhereJwtEffect = (cookieHeader: string) => return body.json.token }) +// Cached for a fixed 1-minute window regardless of the JWT's expiresInSeconds. +// Dashboard JWTs are typically long-lived (15+ minutes), so a 1-minute cache +// reduces issuance calls without risking expired-token propagation. If short-lived +// JWTs are introduced, this should switch to an inline cacheLife profile driven +// by the actual expiration. +async function fetchKnowhereJwtCached( + cookieHeader: string, +): Promise { + "use cache" + cacheLife("minutes") + cacheTag("knowhere-jwt") + + return Effect.runPromise( + fetchKnowhereJwtEffect(cookieHeader).pipe( + Effect.provide(FetchHttpClient.layer), + ), + ) +} + /** * Async wrapper for Next.js boundary callers. */ @@ -95,11 +115,7 @@ export async function fetchKnowhereJwt( ): Promise { const start = Date.now() try { - const token = await Effect.runPromise( - fetchKnowhereJwtEffect(cookieHeader).pipe( - Effect.provide(FetchHttpClient.layer), - ), - ) + const token = await fetchKnowhereJwtCached(cookieHeader) logger.info("dashboard: POST /api/orpc/users/issueServiceJwt ok", { durationMs: Date.now() - start, }) diff --git a/src/integrations/knowhere-demo.test.ts b/src/integrations/knowhere-demo.test.ts index 4018592..926283a 100644 --- a/src/integrations/knowhere-demo.test.ts +++ b/src/integrations/knowhere-demo.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest" +vi.mock("next/cache", () => ({ + cacheLife: () => {}, + cacheTag: () => {}, +})) + import { knowhereDemoApi } from "./knowhere-demo" describe("knowhereDemoApi", () => { diff --git a/src/integrations/knowhere-demo.ts b/src/integrations/knowhere-demo.ts index 37512ca..41e3712 100644 --- a/src/integrations/knowhere-demo.ts +++ b/src/integrations/knowhere-demo.ts @@ -1,6 +1,7 @@ import "server-only" import { Effect } from "effect" +import { cacheLife, cacheTag } from "next/cache" export type DemoCitation = { readonly demoSourceId: string @@ -185,10 +186,7 @@ const emptyCatalog: DemoCatalog = { sources: [] } const fetchCatalogEffect = Effect.fn("knowhereDemo.fetchCatalog")(function* () { const response = yield* Effect.tryPromise(() => - fetch(resolveApiURL("/api/v1/demo/catalog"), { - cache: "force-cache", - next: { revalidate: 300 }, - }), + fetch(resolveApiURL("/api/v1/demo/catalog")), ) yield* assertOkEffect(response) @@ -215,7 +213,6 @@ const fetchChunkPageEffect = Effect.fn("knowhereDemo.fetchChunkPage")( resolveApiURL( `/api/v1/demo/sources/${encodeURIComponent(input.demoSourceId)}/chunks?${params.toString()}`, ), - { cache: "force-cache", next: { revalidate: 300 } }, ), ) yield* assertOkEffect(response) @@ -269,6 +266,10 @@ const fetchOptionalCatalogEffect = ( // --------------------------------------------------------------------------- async function fetchCatalog(): Promise { + "use cache" + cacheLife("hours") + cacheTag("demo-catalog") + return Effect.runPromise(fetchCatalogEffect()) } @@ -289,6 +290,10 @@ async function fetchChunkPage(input: { readonly page: number readonly pageSize: number }): Promise { + "use cache" + cacheLife("hours") + cacheTag("demo-chunks", input.demoSourceId) + return Effect.runPromise(fetchChunkPageEffect(input)) }