feat: land page aggregate and phase7 document ai mainline
- 收口 page aggregate 读取、本地状态与命令客户端\n- 接入 phase7 document ai sidecar 与前端编排入口\n- 更新 architecture 与 design 状态迁移
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockBuildMnoteWebForwardHeaders = vi.fn();
|
||||
const mockBuildMnoteWebStreamUrl = vi.fn();
|
||||
const mockGetAuthedConvexClient = vi.fn();
|
||||
const mockBuildDocumentBridgeContext = vi.fn();
|
||||
const mockBuildDocumentQueryEnvelope = vi.fn();
|
||||
const mockExecuteRustBridgeQueryTransport = vi.fn();
|
||||
const mockResolveRustBridgeQueryPlan = vi.fn();
|
||||
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||
Response.json(
|
||||
{
|
||||
@@ -11,41 +14,99 @@ const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/server/mnote-web", () => ({
|
||||
buildMnoteWebForwardHeaders: mockBuildMnoteWebForwardHeaders,
|
||||
buildMnoteWebStreamUrl: mockBuildMnoteWebStreamUrl,
|
||||
vi.mock("@/lib/convex/route", () => ({
|
||||
getAuthedConvexClient: mockGetAuthedConvexClient,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge", () => ({
|
||||
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
||||
buildDocumentQueryEnvelope: mockBuildDocumentQueryEnvelope,
|
||||
documentBridgeErrorResponse: mockDocumentBridgeErrorResponse,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
executeRustBridgeQueryTransport: mockExecuteRustBridgeQueryTransport,
|
||||
resolveRustBridgeQueryPlan: mockResolveRustBridgeQueryPlan,
|
||||
}));
|
||||
|
||||
describe("/api/mnote-web/stream route", () => {
|
||||
beforeEach(() => {
|
||||
mockBuildMnoteWebForwardHeaders.mockReset();
|
||||
mockBuildMnoteWebStreamUrl.mockReset();
|
||||
mockGetAuthedConvexClient.mockReset();
|
||||
mockBuildDocumentBridgeContext.mockReset();
|
||||
mockBuildDocumentQueryEnvelope.mockReset();
|
||||
mockExecuteRustBridgeQueryTransport.mockReset();
|
||||
mockResolveRustBridgeQueryPlan.mockReset();
|
||||
mockDocumentBridgeErrorResponse.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("透传上游 SSE 并去掉 set-cookie", async () => {
|
||||
const upstreamHeaders = new Headers({
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
"set-cookie": "secret=1",
|
||||
it("直接在 3000 内生成 snapshot SSE,不再回源 mnote-web", async () => {
|
||||
mockGetAuthedConvexClient.mockResolvedValue({
|
||||
client: { query: vi.fn() },
|
||||
});
|
||||
const upstreamResponse = new Response("event: snapshot\ndata: {\"ok\":true}\n\n", {
|
||||
status: 200,
|
||||
headers: upstreamHeaders,
|
||||
mockBuildDocumentBridgeContext.mockResolvedValue({
|
||||
requestId: "req_stream_1",
|
||||
traceId: "trace_stream_1",
|
||||
workspaceId: "ws_1",
|
||||
});
|
||||
|
||||
mockBuildMnoteWebForwardHeaders.mockResolvedValue(new Headers({ cookie: "a=1" }));
|
||||
mockBuildMnoteWebStreamUrl.mockReturnValue(
|
||||
new URL("http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1"),
|
||||
);
|
||||
|
||||
const fetchMock = vi.fn(async () => upstreamResponse);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockBuildDocumentQueryEnvelope
|
||||
.mockReturnValueOnce({
|
||||
name: "sidebar.dataset.list",
|
||||
payload: { workspaceId: "ws_1" },
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
name: "bridge.workspace.overview",
|
||||
payload: {
|
||||
workspaceId: "ws_1",
|
||||
limit: 20,
|
||||
cursor: null,
|
||||
commandStatus: null,
|
||||
eventStatus: null,
|
||||
targetPageId: null,
|
||||
targetBlockId: null,
|
||||
aggregateType: null,
|
||||
aggregateId: null,
|
||||
},
|
||||
});
|
||||
mockResolveRustBridgeQueryPlan
|
||||
.mockResolvedValueOnce({ kind: "query", functionName: "sidebar:datasetList", argsJson: { workspaceId: "ws_1" } })
|
||||
.mockResolvedValueOnce({ kind: "query", functionName: "bridgeLogs:listWorkspaceOverview", argsJson: { workspaceId: "ws_1" } });
|
||||
mockExecuteRustBridgeQueryTransport
|
||||
.mockResolvedValueOnce({
|
||||
active_workspace_id: "ws_1",
|
||||
workspaces: [],
|
||||
documents: [
|
||||
{
|
||||
id: "page_root",
|
||||
workspace_id: "ws_1",
|
||||
title: "工作区首页",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: true,
|
||||
is_template: false,
|
||||
created_at: "2026-04-22T00:00:00Z",
|
||||
updated_at: "2026-04-22T00:00:00Z",
|
||||
},
|
||||
],
|
||||
trashed_documents: [],
|
||||
media_assets: [],
|
||||
trashed_media_assets: [],
|
||||
mindmap_assets: [],
|
||||
trashed_mindmap_assets: [],
|
||||
table_assets: [],
|
||||
trashed_table_assets: [],
|
||||
mindmap_docs: [],
|
||||
mindmap_asset_children: {},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
workspace_id: "ws_1",
|
||||
command_logs: [],
|
||||
domain_events: [],
|
||||
counts: { command_logs: 0, domain_events: 0 },
|
||||
filters: null,
|
||||
next_cursor: null,
|
||||
has_more: false,
|
||||
generated_at: "2026-04-22T00:00:00Z",
|
||||
});
|
||||
|
||||
const { GET } = await import("./route");
|
||||
const response = await GET(
|
||||
@@ -55,19 +116,15 @@ describe("/api/mnote-web/stream route", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
||||
expect(response.headers.get("set-cookie")).toBeNull();
|
||||
await expect(response.text()).resolves.toContain("event: snapshot");
|
||||
const text = await response.text();
|
||||
expect(text).toContain("event: snapshot");
|
||||
expect(text).toContain('"kind":"snapshot"');
|
||||
expect(text).toContain('"projection":"sidebar_tree"');
|
||||
expect(text).toContain('"workspaceId":"ws_1"');
|
||||
expect(text).toContain('"activeWorkspaceId":"ws_1"');
|
||||
expect(mockResolveRustBridgeQueryPlan).toHaveBeenCalledTimes(2);
|
||||
expect(mockExecuteRustBridgeQueryTransport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
buildMnoteWebForwardHeaders,
|
||||
buildMnoteWebStreamUrl,
|
||||
} from "@/lib/server/mnote-web";
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentQueryEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executeRustBridgeQueryTransport,
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
import { mapSidebarDatasetListQueryResultToInitialData } from "@/lib/sidebar-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function toSseFrame(event: string, data: unknown) {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data ?? null)}\n\n`;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const requestUrl = new URL(request.url);
|
||||
@@ -16,26 +26,73 @@ export async function GET(request: Request) {
|
||||
return Response.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const targetUrl = buildMnoteWebStreamUrl({ workspaceId, cursor });
|
||||
const headers = await buildMnoteWebForwardHeaders(request);
|
||||
headers.set("accept", "text/event-stream");
|
||||
headers.set("x-mnote-workspace-id", workspaceId);
|
||||
|
||||
const upstream = await fetch(targetUrl.toString(), {
|
||||
method: "GET",
|
||||
headers,
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const responseHeaders = new Headers(upstream.headers);
|
||||
responseHeaders.delete("set-cookie");
|
||||
responseHeaders.set("cache-control", "no-store");
|
||||
const sidebarEnvelope = buildDocumentQueryEnvelope({
|
||||
name: "sidebar.dataset.list",
|
||||
payload: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
const sidebarPlan = await resolveRustBridgeQueryPlan({
|
||||
context,
|
||||
envelope: sidebarEnvelope,
|
||||
});
|
||||
const sidebarDataset = await executeRustBridgeQueryTransport({
|
||||
client,
|
||||
plan: sidebarPlan,
|
||||
});
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers: responseHeaders,
|
||||
const overviewEnvelope = buildDocumentQueryEnvelope({
|
||||
name: "bridge.workspace.overview",
|
||||
payload: {
|
||||
workspaceId,
|
||||
limit: 20,
|
||||
cursor,
|
||||
commandStatus: null,
|
||||
eventStatus: null,
|
||||
targetPageId: null,
|
||||
targetBlockId: null,
|
||||
aggregateType: null,
|
||||
aggregateId: null,
|
||||
},
|
||||
});
|
||||
const overviewPlan = await resolveRustBridgeQueryPlan({
|
||||
context,
|
||||
envelope: overviewEnvelope,
|
||||
});
|
||||
const overview = await executeRustBridgeQueryTransport({
|
||||
client,
|
||||
plan: overviewPlan,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
kind: "snapshot",
|
||||
stream: "workspace",
|
||||
projection: "sidebar_tree",
|
||||
workspaceId,
|
||||
rootNodeId: null,
|
||||
cursor,
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
data: mapSidebarDatasetListQueryResultToInitialData(sidebarDataset),
|
||||
snapshot: {
|
||||
dataset: sidebarDataset,
|
||||
tree: sidebarDataset.kernel_sidebar_projection ?? sidebarDataset.kernelSidebarProjection ?? null,
|
||||
},
|
||||
overview,
|
||||
};
|
||||
|
||||
return new Response(toSseFrame("snapshot", payload), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
|
||||
Reference in New Issue
Block a user