From b9463384d43fa638f5d204f3229950920289a518 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Wed, 13 May 2026 20:59:19 +0800 Subject: [PATCH 01/10] fix: clean up SSR demo chunk prefetch stub and hardcoded values Replace over-specified fake DemoSource with a minimal SourceView since toParsedChunkView only uses title and documentId. Extract magic page size 100 to a named constant aligned with the client-side page size (50). Add cache tags for future on-demand invalidation and a migration note for "use cache" when cacheComponents is enabled. --- src/domains/workspace/initial-state.ts | 32 +++++++++++++++----------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/domains/workspace/initial-state.ts b/src/domains/workspace/initial-state.ts index d11e1fb..08b5f19 100644 --- a/src/domains/workspace/initial-state.ts +++ b/src/domains/workspace/initial-state.ts @@ -50,36 +50,40 @@ type WorkspaceShellInitialState = { } } +// 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 + +// Migrate to "use cache" + cacheLife("max") + cacheTag("demo-chunks") +// when cacheComponents is enabled in next.config.ts. const getCachedDemoChunksForSource = (demoSourceId: string) => unstable_cache( async (): Promise => { const chunkPage = await knowhereDemoApi.fetchChunkPage({ demoSourceId, page: 1, - pageSize: 100, + pageSize: DEMO_CHUNK_PREFETCH_PAGE_SIZE, }) - const sourceView = demoView.toSourceView({ + // 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, - 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: [], - }) + documentId: chunkPage.canonicalDocumentId, + } return chunkPage.chunks.map((chunk) => demoView.toParsedChunkView(sourceView, chunk), ) }, ["demo-chunks", demoSourceId], - { revalidate: false }, + { + revalidate: false, + tags: ["demo-chunks"], + }, )() type WorkspaceShellInitialStateClient = From f56059359394e4c8e151d91e61488fa7d34145e5 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Wed, 13 May 2026 21:11:07 +0800 Subject: [PATCH 02/10] feat: migrate from unstable_cache to "use cache" directive Enable cacheComponents in next.config.ts and replace the unstable_cache wrapper with a "use cache" async function using cacheLife("max") and cacheTag("demo-chunks", demoSourceId). The cache key is now automatically derived from the function's arguments rather than manually specified. The home page is restructured with a Suspense boundary so the static shell can be prerendered while dynamic content (auth, guest context) streams in at request time via connection(). --- next.config.ts | 1 + src/app/page.tsx | 13 +++++- src/domains/workspace/initial-state.ts | 60 ++++++++++++-------------- 3 files changed, 40 insertions(+), 34 deletions(-) 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/page.tsx b/src/app/page.tsx index 7a27368..7449abc 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() { +async function HomeContent() { + await connection() return } diff --git a/src/domains/workspace/initial-state.ts b/src/domains/workspace/initial-state.ts index 08b5f19..160d939 100644 --- a/src/domains/workspace/initial-state.ts +++ b/src/domains/workspace/initial-state.ts @@ -1,6 +1,6 @@ import "server-only" -import { unstable_cache } from "next/cache" +import { cacheLife, cacheTag } from "next/cache" import { Effect } from "effect" import type { ChatMessageView } from "@/domains/chat/types" @@ -54,37 +54,33 @@ type WorkspaceShellInitialState = { // prefetch doesn't overlap with the first client-side page request. const DEMO_CHUNK_PREFETCH_PAGE_SIZE = 50 -// Migrate to "use cache" + cacheLife("max") + cacheTag("demo-chunks") -// when cacheComponents is enabled in next.config.ts. -const getCachedDemoChunksForSource = (demoSourceId: string) => - unstable_cache( - async (): 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), - ) - }, - ["demo-chunks", demoSourceId], - { - revalidate: false, - tags: ["demo-chunks"], - }, - )() +async function getCachedDemoChunksForSource( + demoSourceId: string, +): Promise { + "use cache" + cacheLife("max") + cacheTag("demo-chunks", demoSourceId) + + 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] From fda9d37a6652352017321c13bbd7abff4082cd41 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Wed, 13 May 2026 21:19:25 +0800 Subject: [PATCH 03/10] fix: extract renderWorkspaceShell helper for testability connection() can't be called in test environment (no request scope). Extract the render logic into an exported renderWorkspaceShell() so the test can exercise the data-fetching path without triggering the connection() boundary. --- src/app/page.test.ts | 4 ++-- src/app/page.tsx | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/page.test.ts b/src/app/page.test.ts index 3ce551b..8cabb66 100644 --- a/src/app/page.test.ts +++ b/src/app/page.test.ts @@ -9,7 +9,7 @@ vi.mock("@/domains/workspace/initial-state", () => ({ loadWorkspaceShellInitialState: mocks.loadWorkspaceShellInitialState, })) -import Home from "./page" +import { renderWorkspaceShell } from "./page" describe("Home", () => { it("renders the workspace shell from the API-backed initial state", async () => { @@ -20,7 +20,7 @@ describe("Home", () => { chatMessages: [], }) - const element = await Home() + const element = await renderWorkspaceShell() expect(React.isValidElement(element)).toBe(true) expect(mocks.loadWorkspaceShellInitialState).toHaveBeenCalledOnce() diff --git a/src/app/page.tsx b/src/app/page.tsx index 7449abc..8546e2e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -13,5 +13,9 @@ export default function Home() { async function HomeContent() { await connection() + return renderWorkspaceShell() +} + +export async function renderWorkspaceShell() { return } From 22dae4ee3bb83303606f288bd3edaeff2baea04e Mon Sep 17 00:00:00 2001 From: suguanYang Date: Wed, 13 May 2026 21:31:16 +0800 Subject: [PATCH 04/10] feat: add "use cache" to demo catalog and chunk page fetches Replace the fetch-level cache:force-cache with "use cache" directives at the async wrapper layer in knowhere-demo.ts. This catches all callers (SSR page, API routes) and integrates with Next.js 16's cache tagging for on-demand invalidation. - fetchCatalog: cacheLife("hours") + cacheTag("demo-catalog") - fetchChunkPage: cacheLife("hours") + cacheTag("demo-chunks", id) The outer "use cache" in initial-state.ts is removed since the integration layer now handles caching. Effect functions are exported so tests can call them without triggering cacheLife(). --- src/domains/workspace/initial-state.ts | 9 ++------- src/integrations/knowhere-demo.test.ts | 15 +++++++++------ src/integrations/knowhere-demo.ts | 19 ++++++++++++------- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/domains/workspace/initial-state.ts b/src/domains/workspace/initial-state.ts index 160d939..5d4ee9a 100644 --- a/src/domains/workspace/initial-state.ts +++ b/src/domains/workspace/initial-state.ts @@ -1,6 +1,5 @@ import "server-only" -import { cacheLife, cacheTag } from "next/cache" import { Effect } from "effect" import type { ChatMessageView } from "@/domains/chat/types" @@ -54,13 +53,9 @@ type WorkspaceShellInitialState = { // prefetch doesn't overlap with the first client-side page request. const DEMO_CHUNK_PREFETCH_PAGE_SIZE = 50 -async function getCachedDemoChunksForSource( +async function getDemoChunksForSource( demoSourceId: string, ): Promise { - "use cache" - cacheLife("max") - cacheTag("demo-chunks", demoSourceId) - const chunkPage = await knowhereDemoApi.fetchChunkPage({ demoSourceId, page: 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/integrations/knowhere-demo.test.ts b/src/integrations/knowhere-demo.test.ts index 4018592..4e8b800 100644 --- a/src/integrations/knowhere-demo.test.ts +++ b/src/integrations/knowhere-demo.test.ts @@ -1,6 +1,7 @@ +import { Effect } from "effect" import { afterEach, describe, expect, it, vi } from "vitest" -import { knowhereDemoApi } from "./knowhere-demo" +import { fetchChunkPageEffect, knowhereDemoApi } from "./knowhere-demo" describe("knowhereDemoApi", () => { const originalBaseURL = process.env.KNOWHERE_BASE_URL @@ -60,11 +61,13 @@ describe("knowhereDemoApi", () => { ), ) - const page = await knowhereDemoApi.fetchChunkPage({ - demoSourceId: "demo-tsla-q4-2025", - page: 1, - pageSize: 100, - }) + const page = await Effect.runPromise( + fetchChunkPageEffect({ + demoSourceId: "demo-tsla-q4-2025", + page: 1, + pageSize: 100, + }), + ) expect(page.chunks[0]).toMatchObject({ id: "demo-tsla-q4-2025:chunk-empty", diff --git a/src/integrations/knowhere-demo.ts b/src/integrations/knowhere-demo.ts index 37512ca..6a05535 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 @@ -183,12 +184,9 @@ const emptyCatalog: DemoCatalog = { sources: [] } // Effect core // --------------------------------------------------------------------------- -const fetchCatalogEffect = Effect.fn("knowhereDemo.fetchCatalog")(function* () { +export 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) @@ -200,7 +198,7 @@ const fetchCatalogEffect = Effect.fn("knowhereDemo.fetchCatalog")(function* () { } }) -const fetchChunkPageEffect = Effect.fn("knowhereDemo.fetchChunkPage")( +export const fetchChunkPageEffect = Effect.fn("knowhereDemo.fetchChunkPage")( function* (input: { readonly demoSourceId: string readonly page: number @@ -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)) } From 0c74d8b9832ee4f9b910be7a9332c04416e0321a Mon Sep 17 00:00:00 2001 From: suguanYang Date: Wed, 13 May 2026 21:44:20 +0800 Subject: [PATCH 05/10] feat: add "use cache" to getCurrentUser and fetchKnowhereJwt Both functions call Dashboard oRPC endpoints on every SSR request. Caching for "minutes" (1-minute revalidate) reduces Dashboard load for rapid repeat requests while keeping session data reasonably fresh. - getCurrentUser: cookie read extracted to outer function, Effect execution moved to cached inner function getCurrentUserCached - fetchKnowhereJwt: Effect execution moved to cached inner function fetchKnowhereJwtCached (cookieHeader already an explicit argument) - Tests mock next/cache as no-ops since cacheLife/cacheTag require the Next.js runtime --- src/infrastructure/auth/index.test.ts | 5 +++++ src/infrastructure/auth/index.ts | 17 ++++++++++++--- .../dashboard/api-key-service.test.ts | 5 +++++ src/integrations/dashboard/api-key-service.ts | 21 ++++++++++++++----- 4 files changed, 40 insertions(+), 8 deletions(-) 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..5b55a0b 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, @@ -150,6 +151,18 @@ 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( + getCurrentUserEffect.pipe(Effect.provide(FetchHttpClient.layer)), + ) +} + export async function getCurrentUser(): Promise { const developmentUser = knowhereApiKeyOverride.getDevelopmentUser() if (developmentUser) { @@ -166,9 +179,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..0f3350c 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,20 @@ export const fetchKnowhereJwtEffect = (cookieHeader: string) => return body.json.token }) +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 +110,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, }) From 8572c08b81802776ff84c4fea0ae4f7f18b23a79 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Wed, 13 May 2026 21:54:54 +0800 Subject: [PATCH 06/10] fix: pass cookieHeader through to cached getCurrentUser call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cached function was calling getCurrentUserEffect which reads headers() internally, ignoring the cookieHeader parameter. This meant the cache key was always empty — the first user's session would be returned for all subsequent users. Extract callGetCurrentUser(cookieHeader) as a shared Effect so the cached path uses the explicit cookie header (different cache key per session) while getCurrentUserEffect continues to read headers() for the Auth service layer. --- src/infrastructure/auth/index.ts | 129 ++++++++++++++++--------------- 1 file changed, 67 insertions(+), 62 deletions(-) diff --git a/src/infrastructure/auth/index.ts b/src/infrastructure/auth/index.ts index 5b55a0b..6674e74 100644 --- a/src/infrastructure/auth/index.ts +++ b/src/infrastructure/auth/index.ts @@ -58,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 --------------------------------------------------------- @@ -159,7 +162,9 @@ async function getCurrentUserCached( cacheTag("current-user") return Effect.runPromise( - getCurrentUserEffect.pipe(Effect.provide(FetchHttpClient.layer)), + callGetCurrentUser(cookieHeader).pipe( + Effect.provide(FetchHttpClient.layer), + ), ) } From 5d71f9dd05a71909363a4ab993f3bc1e8e6605d1 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Wed, 13 May 2026 22:08:24 +0800 Subject: [PATCH 07/10] fix: add Suspense boundary to login page for cacheComponents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The login page reads headers() to resolve the Notebook public URL for the Dashboard callback. With cacheComponents enabled, request-time APIs must be wrapped in a Suspense boundary with connection(). Extracts renderLoginPage() for testability — tests call it directly to avoid the connection() call which requires Next.js runtime. --- src/app/login/page.test.ts | 6 +++--- src/app/login/page.tsx | 21 ++++++++++++++++----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/app/login/page.test.ts b/src/app/login/page.test.ts index 3d10dd5..9f9f576 100644 --- a/src/app/login/page.test.ts +++ b/src/app/login/page.test.ts @@ -2,7 +2,7 @@ import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import LoginPage from "./page"; +import { renderLoginPage } from "./page"; describe("LoginPage", () => { const originalDashboardOrigin = process.env.DASHBOARD_ORIGIN; @@ -30,7 +30,7 @@ describe("LoginPage", () => { }); it("links directly to Dashboard login with the Notebook callback URL", async () => { - render(await LoginPage()); + render(await renderLoginPage()); const link = screen.getByRole("link", { name: "Sign in" }); @@ -41,7 +41,7 @@ describe("LoginPage", () => { }); it("uses account language instead of implementation details", async () => { - const { container } = render(await LoginPage()); + const { container } = render(await renderLoginPage()); 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..6781c51 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,14 +1,25 @@ +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 ( + + + + ) +} + +async function LoginContent() { + await connection() + return renderLoginPage() +} + +export async function renderLoginPage() { const notebookPublicURL = process.env.NOTEBOOK_PUBLIC_URL ?? authURLs.resolveNotebookPublicURLFromHeaders(await headers()); From 509cf36c987f1b047bb1cebd7c089bb037bed816 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Wed, 13 May 2026 22:38:08 +0800 Subject: [PATCH 08/10] refactor: remove test-only page exports, mock runtime deps in tests - Export HomeContent/LoginContent directly instead of renderWorkspaceShell/ renderLoginPage wrappers. Tests mock next/server (connection) so the components work without Next.js runtime. - Un-export fetchCatalogEffect/fetchChunkPageEffect. Tests mock next/cache (cacheLife/cacheTag) so knowhereDemoApi.fetchChunkPage works directly. - Add comment documenting the fixed 1-min JWT cache vs. expiresInSeconds tradeoff. --- src/app/login/page.test.ts | 12 +++++++---- src/app/login/page.tsx | 6 +----- src/app/page.test.ts | 8 ++++++-- src/app/page.tsx | 6 +----- src/integrations/dashboard/api-key-service.ts | 5 +++++ src/integrations/knowhere-demo.test.ts | 20 ++++++++++--------- src/integrations/knowhere-demo.ts | 4 ++-- 7 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/app/login/page.test.ts b/src/app/login/page.test.ts index 9f9f576..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 { renderLoginPage } 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 renderLoginPage()); + 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 renderLoginPage()); + 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 6781c51..c802af3 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -14,12 +14,8 @@ export default function LoginPage() { ) } -async function LoginContent() { +export async function LoginContent() { await connection() - return renderLoginPage() -} - -export async function renderLoginPage() { 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 8cabb66..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 { renderWorkspaceShell } 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 renderWorkspaceShell() + 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 8546e2e..b2efa13 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -11,11 +11,7 @@ export default function Home() { ) } -async function HomeContent() { +export async function HomeContent() { await connection() - return renderWorkspaceShell() -} - -export async function renderWorkspaceShell() { return } diff --git a/src/integrations/dashboard/api-key-service.ts b/src/integrations/dashboard/api-key-service.ts index 0f3350c..410a06a 100644 --- a/src/integrations/dashboard/api-key-service.ts +++ b/src/integrations/dashboard/api-key-service.ts @@ -88,6 +88,11 @@ 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 { diff --git a/src/integrations/knowhere-demo.test.ts b/src/integrations/knowhere-demo.test.ts index 4e8b800..926283a 100644 --- a/src/integrations/knowhere-demo.test.ts +++ b/src/integrations/knowhere-demo.test.ts @@ -1,7 +1,11 @@ -import { Effect } from "effect" import { afterEach, describe, expect, it, vi } from "vitest" -import { fetchChunkPageEffect, knowhereDemoApi } from "./knowhere-demo" +vi.mock("next/cache", () => ({ + cacheLife: () => {}, + cacheTag: () => {}, +})) + +import { knowhereDemoApi } from "./knowhere-demo" describe("knowhereDemoApi", () => { const originalBaseURL = process.env.KNOWHERE_BASE_URL @@ -61,13 +65,11 @@ describe("knowhereDemoApi", () => { ), ) - const page = await Effect.runPromise( - fetchChunkPageEffect({ - demoSourceId: "demo-tsla-q4-2025", - page: 1, - pageSize: 100, - }), - ) + const page = await knowhereDemoApi.fetchChunkPage({ + demoSourceId: "demo-tsla-q4-2025", + page: 1, + pageSize: 100, + }) expect(page.chunks[0]).toMatchObject({ id: "demo-tsla-q4-2025:chunk-empty", diff --git a/src/integrations/knowhere-demo.ts b/src/integrations/knowhere-demo.ts index 6a05535..41e3712 100644 --- a/src/integrations/knowhere-demo.ts +++ b/src/integrations/knowhere-demo.ts @@ -184,7 +184,7 @@ const emptyCatalog: DemoCatalog = { sources: [] } // Effect core // --------------------------------------------------------------------------- -export const fetchCatalogEffect = Effect.fn("knowhereDemo.fetchCatalog")(function* () { +const fetchCatalogEffect = Effect.fn("knowhereDemo.fetchCatalog")(function* () { const response = yield* Effect.tryPromise(() => fetch(resolveApiURL("/api/v1/demo/catalog")), ) @@ -198,7 +198,7 @@ export const fetchCatalogEffect = Effect.fn("knowhereDemo.fetchCatalog")(functio } }) -export const fetchChunkPageEffect = Effect.fn("knowhereDemo.fetchChunkPage")( +const fetchChunkPageEffect = Effect.fn("knowhereDemo.fetchChunkPage")( function* (input: { readonly demoSourceId: string readonly page: number From 2409927b9dde8f471c6493b4be78e27e93526c7a Mon Sep 17 00:00:00 2001 From: suguanYang Date: Wed, 13 May 2026 22:53:42 +0800 Subject: [PATCH 09/10] fix: use canonicalDocumentId for citation source matching findCitationSource matches source.documentId === citation.source.documentId, but these were populated from different API fields: - source.documentId <- canonical_document_id - citation.source.documentId <- source.document_id (nested) When the demo API returns different values for these fields, the lookup returns null and citation clicks silently do nothing. Use the citation's canonicalDocumentId instead, which maps to the same canonical_document_id field as the source. --- src/domains/demo/view.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, }, From 6f2ba16fec8d01c6c482aedc70be14f8ac0ac945 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Wed, 13 May 2026 23:51:14 +0800 Subject: [PATCH 10/10] fix: resolve chunk connections in SSR-prefetched chunks SSR-prefetched chunks bypassed resolveChunkConnectionTargets, leaving targetChunkId null on all chunk-to-chunk references. With isResolved === false, the reference buttons were disabled in the chunks panel. Wrap prefetched chunks through resolveChunkConnectionTargets so cross- chunk references are clickable in guest mode. --- src/components/workspace-selected-chunks.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 &&