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))
}