feat: land page aggregate and phase7 document ai mainline
- 收口 page aggregate 读取、本地状态与命令客户端\n- 接入 phase7 document ai sidecar 与前端编排入口\n- 更新 architecture 与 design 状态迁移
This commit is contained in:
@@ -134,6 +134,9 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
|
||||
"documents.stats.update": "documents:updateStats",
|
||||
"documents.options.update": "documents:updateOptions",
|
||||
"documents.save": "documents:updateContent",
|
||||
"page.head.updateTitle": "documents:updateTitle",
|
||||
"page.layout.updateOptions": "documents:updateOptions",
|
||||
"page.body.save": "documents:updateContent",
|
||||
"mindmaps.delete": "mindmaps:softDelete",
|
||||
"mindmaps.restore": "mindmaps:restore",
|
||||
"mindmaps.purge": "mindmaps:purge",
|
||||
|
||||
@@ -84,6 +84,13 @@ const metadataWriteAdapters: Record<string, MetadataWriteAdapter<unknown>> = {
|
||||
title: payload.title,
|
||||
}),
|
||||
},
|
||||
"page.head.updateTitle": {
|
||||
convexMutation: api.documents.updateTitle,
|
||||
mapConvexArgs: (payload: DocumentTitleUpdatePayload) => ({
|
||||
id: payload.documentId,
|
||||
title: payload.title,
|
||||
}),
|
||||
},
|
||||
"documents.stats.update": {
|
||||
convexMutation: api.documents.updateStats,
|
||||
mapConvexArgs: (payload: DocumentStatsUpdatePayload) => ({
|
||||
@@ -99,6 +106,10 @@ const metadataWriteAdapters: Record<string, MetadataWriteAdapter<unknown>> = {
|
||||
convexMutation: api.documents.updateOptions,
|
||||
mapConvexArgs: mapDocumentOptionsToConvexArgs,
|
||||
},
|
||||
"page.layout.updateOptions": {
|
||||
convexMutation: api.documents.updateOptions,
|
||||
mapConvexArgs: mapDocumentOptionsToConvexArgs,
|
||||
},
|
||||
};
|
||||
|
||||
function getMetadataWriteAdapter<TPayload>(commandName: string): MetadataWriteAdapter<TPayload> {
|
||||
@@ -115,7 +126,10 @@ export async function executeMetadataBridgeCommand<TPayload>(input: {
|
||||
}): Promise<MetadataCommandExecutionResult> {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
try {
|
||||
if (input.envelope.name === "documents.title.update") {
|
||||
if (
|
||||
input.envelope.name === "documents.title.update" ||
|
||||
input.envelope.name === "page.head.updateTitle"
|
||||
) {
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildPageAggregateFromDocumentPayloads } from "@/lib/documents/page-aggregate-builder";
|
||||
|
||||
describe("page-aggregate-builder", () => {
|
||||
it("会把 documents meta 与 content 收口为统一 page aggregate", () => {
|
||||
const aggregate = buildPageAggregateFromDocumentPayloads({
|
||||
meta: {
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
title: " 页面标题 ",
|
||||
updated_at: "2026-04-22T12:00:00.000Z",
|
||||
can_edit: false,
|
||||
disable_download: true,
|
||||
disable_copy: false,
|
||||
wide_layout: true,
|
||||
use_small_text: true,
|
||||
show_heading_numbers: false,
|
||||
show_toc: true,
|
||||
show_structure: false,
|
||||
protect_editing: false,
|
||||
show_word_count: true,
|
||||
collapse_backlinks: true,
|
||||
page_font: "default",
|
||||
layout_density: "compact",
|
||||
hide_child_pages: true,
|
||||
show_block_ref_count: true,
|
||||
embed_default_block_id: "block_1",
|
||||
word_count: 20,
|
||||
character_count: 40,
|
||||
block_count: 2,
|
||||
todo_total: 3,
|
||||
todo_done: 1,
|
||||
},
|
||||
contentPayload: {
|
||||
content: [{ id: "block_1", type: "paragraph", content: [] }],
|
||||
revision: 8,
|
||||
conflict_detection_key: "doc_1:8",
|
||||
page_subtree: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(aggregate.identity).toEqual({
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
});
|
||||
expect(aggregate.head).toEqual({
|
||||
title: "页面标题",
|
||||
updatedAt: "2026-04-22T12:00:00.000Z",
|
||||
permissions: {
|
||||
readOnly: true,
|
||||
disableDownload: true,
|
||||
disableCopy: false,
|
||||
},
|
||||
});
|
||||
expect(aggregate.layout.pageOptions).toMatchObject({
|
||||
wideLayout: true,
|
||||
smallText: true,
|
||||
showHeadingNumbers: false,
|
||||
showToc: true,
|
||||
collapseBacklinks: true,
|
||||
layoutDensity: "compact",
|
||||
hideChildPages: true,
|
||||
showBlockRefCount: true,
|
||||
embedDefaultBlockId: "block_1",
|
||||
});
|
||||
expect(aggregate.body).toEqual({
|
||||
content: [{ id: "block_1", type: "paragraph", content: [] }],
|
||||
revision: 8,
|
||||
conflictDetectionKey: "doc_1:8",
|
||||
});
|
||||
expect(aggregate.stats).toEqual({
|
||||
wordCount: 20,
|
||||
characterCount: 40,
|
||||
blockCount: 2,
|
||||
todoTotal: 3,
|
||||
todoDone: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { buildPageAggregate, type PageAggregateProjection } from "@/lib/documents/page-aggregate";
|
||||
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
|
||||
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import type {
|
||||
DocumentStats,
|
||||
PageFont,
|
||||
PageLayoutDensity,
|
||||
PageOptionsState,
|
||||
} from "@/types/page-options";
|
||||
|
||||
export type DocumentMetaPayload = {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
title: string | null;
|
||||
updated_at: string | null;
|
||||
can_edit?: boolean | null;
|
||||
disable_download?: boolean | null;
|
||||
disable_copy?: boolean | null;
|
||||
wide_layout?: boolean | null;
|
||||
use_small_text?: boolean | null;
|
||||
show_heading_numbers?: boolean | null;
|
||||
show_toc?: boolean | null;
|
||||
show_structure?: boolean | null;
|
||||
protect_editing?: boolean | null;
|
||||
show_word_count?: boolean | null;
|
||||
collapse_backlinks?: boolean | null;
|
||||
page_font?: PageFont | null;
|
||||
layout_density?: PageLayoutDensity | null;
|
||||
hide_child_pages?: boolean | null;
|
||||
show_block_ref_count?: boolean | null;
|
||||
embed_default_block_id?: string | null;
|
||||
word_count?: number | null;
|
||||
character_count?: number | null;
|
||||
block_count?: number | null;
|
||||
todo_total?: number | null;
|
||||
todo_total_count?: number | null;
|
||||
todo_done?: number | null;
|
||||
todo_done_count?: number | null;
|
||||
};
|
||||
|
||||
export type DocumentContentPayload = {
|
||||
content?: unknown;
|
||||
revision?: number | null;
|
||||
conflict_detection_key?: string | null;
|
||||
conflictDetectionKey?: string | null;
|
||||
page_subtree?: PageSubtreeProjection | null;
|
||||
pageSubtree?: PageSubtreeProjection | null;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
export function buildPageAggregateFromDocumentPayloads(input: {
|
||||
meta: DocumentMetaPayload;
|
||||
contentPayload?: DocumentContentPayload | null;
|
||||
}): PageAggregateProjection {
|
||||
const normalizedContent = normalizeDocumentContentResponse({
|
||||
documentId: input.meta.id,
|
||||
title: input.meta.title ?? "无标题",
|
||||
payload: input.contentPayload,
|
||||
});
|
||||
|
||||
const pageOptions: PageOptionsState = {
|
||||
wideLayout: input.meta.wide_layout ?? false,
|
||||
smallText: input.meta.use_small_text ?? false,
|
||||
showHeadingNumbers: input.meta.show_heading_numbers ?? true,
|
||||
showToc: input.meta.show_toc ?? false,
|
||||
showStructure: input.meta.show_structure ?? false,
|
||||
protectEditing: input.meta.protect_editing ?? false,
|
||||
showWordCount: input.meta.show_word_count ?? true,
|
||||
collapseBacklinks: input.meta.collapse_backlinks ?? false,
|
||||
pageFont: input.meta.page_font ?? "default",
|
||||
layoutDensity: input.meta.layout_density ?? "normal",
|
||||
hideChildPages: input.meta.hide_child_pages ?? false,
|
||||
showBlockRefCount: input.meta.show_block_ref_count ?? false,
|
||||
embedDefaultBlockId: input.meta.embed_default_block_id ?? null,
|
||||
};
|
||||
|
||||
const stats: DocumentStats = {
|
||||
wordCount: input.meta.word_count ?? 0,
|
||||
characterCount: input.meta.character_count ?? 0,
|
||||
blockCount: input.meta.block_count ?? 0,
|
||||
todoTotal: input.meta.todo_total ?? input.meta.todo_total_count ?? 0,
|
||||
todoDone: input.meta.todo_done ?? input.meta.todo_done_count ?? 0,
|
||||
};
|
||||
|
||||
return buildPageAggregate({
|
||||
documentId: input.meta.id,
|
||||
workspaceId: input.meta.workspace_id,
|
||||
title: input.meta.title ?? "无标题",
|
||||
updatedAt: input.meta.updated_at,
|
||||
readOnly: input.meta.can_edit === false,
|
||||
disableDownload: Boolean(input.meta.disable_download),
|
||||
disableCopy: Boolean(input.meta.disable_copy),
|
||||
pageOptions,
|
||||
content: normalizedContent.content,
|
||||
revision: normalizedContent.revision,
|
||||
conflictDetectionKey: normalizedContent.conflictDetectionKey,
|
||||
pageSubtree: normalizedContent.pageSubtree,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { headers } from "next/headers";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentQueryEnvelope,
|
||||
type BridgeContext,
|
||||
} from "@/lib/documents/bridge";
|
||||
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
|
||||
import {
|
||||
buildPageAggregateFromDocumentPayloads,
|
||||
type DocumentContentPayload,
|
||||
type DocumentMetaPayload,
|
||||
} from "@/lib/documents/page-aggregate-builder";
|
||||
import {
|
||||
executeRustBridgeQueryTransport,
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
const FORWARDED_REQUEST_HEADERS = [
|
||||
"cookie",
|
||||
"authorization",
|
||||
"x-request-id",
|
||||
"x-trace-id",
|
||||
"x-session-id",
|
||||
"x-source-channel",
|
||||
"x-source-client",
|
||||
"user-agent",
|
||||
] as const;
|
||||
|
||||
export type LoadedPageAggregate = {
|
||||
page: PageAggregateProjection;
|
||||
bridge: {
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
queryName: "documents.page.get";
|
||||
};
|
||||
};
|
||||
|
||||
function copyForwardHeaderIfPresent(target: Headers, source: Headers, name: string) {
|
||||
const value = source.get(name);
|
||||
if (value) {
|
||||
target.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildServerBridgeRequest(pathname: string): Promise<Request> {
|
||||
const headerList = await headers();
|
||||
const requestHeaders = new Headers();
|
||||
FORWARDED_REQUEST_HEADERS.forEach((name) => {
|
||||
copyForwardHeaderIfPresent(requestHeaders, headerList, name);
|
||||
});
|
||||
|
||||
return new Request(`http://mnote.local${pathname}`, {
|
||||
method: "GET",
|
||||
headers: requestHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchDocumentContentPayload(input: {
|
||||
context: BridgeContext;
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<DocumentContentPayload | null> {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "documents.content.get",
|
||||
payload: {
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeQueryPlan({
|
||||
context: input.context,
|
||||
envelope,
|
||||
});
|
||||
|
||||
return executeRustBridgeQueryTransport<DocumentContentPayload | null>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPageAggregate(input: {
|
||||
request: Request;
|
||||
documentId: string;
|
||||
workspaceId?: string | null;
|
||||
}): Promise<LoadedPageAggregate | null> {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const meta = await client.query(api.documents.getMeta, {
|
||||
id: input.documentId,
|
||||
});
|
||||
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceId = input.workspaceId?.trim() || meta.workspace_id;
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request: input.request,
|
||||
workspaceId,
|
||||
});
|
||||
const contentPayload = await fetchDocumentContentPayload({
|
||||
context,
|
||||
documentId: meta.id,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
page: buildPageAggregateFromDocumentPayloads({
|
||||
meta,
|
||||
contentPayload,
|
||||
}),
|
||||
bridge: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
queryName: "documents.page.get",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadPageAggregateFromNextHeaders(input: {
|
||||
documentId: string;
|
||||
workspaceId?: string | null;
|
||||
}): Promise<LoadedPageAggregate | null> {
|
||||
const request = await buildServerBridgeRequest("/documents/page");
|
||||
return loadPageAggregate({
|
||||
request,
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
}
|
||||
@@ -1,76 +1,7 @@
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { buildDocumentSavePayload, type DocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
|
||||
export type PageBodyPersistedMeta = {
|
||||
revision: number | null;
|
||||
conflictDetectionKey: string | null;
|
||||
};
|
||||
|
||||
export type PageBodyPersistedState = PageBodyPersistedMeta & {
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
export type ApplyPageBodyCommandInput = {
|
||||
documentId: string;
|
||||
workspaceId: string | null;
|
||||
revision: number | null;
|
||||
conflictDetectionKey: string | null;
|
||||
blocks: Json;
|
||||
applyEditorSnapshot?: (blocks: Json) => void;
|
||||
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
|
||||
fetchImpl?: typeof fetch;
|
||||
persistPageBody?: (payload: DocumentSavePayload) => Promise<PageBodyPersistedMeta>;
|
||||
};
|
||||
|
||||
async function persistPageBodyViaRoute(
|
||||
payload: DocumentSavePayload,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<PageBodyPersistedMeta> {
|
||||
const response = await fetchImpl("/api/documents/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| {
|
||||
ok?: boolean;
|
||||
revision?: number | null;
|
||||
conflictDetectionKey?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
| null;
|
||||
|
||||
if (!response.ok) {
|
||||
const message = body && typeof body.error === "string" && body.error.trim() ? body.error.trim() : "页面正文保存失败";
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return {
|
||||
revision:
|
||||
typeof body?.revision === "number" && Number.isInteger(body.revision) ? body.revision : payload.revision,
|
||||
conflictDetectionKey:
|
||||
typeof body?.conflictDetectionKey === "string" && body.conflictDetectionKey.trim()
|
||||
? body.conflictDetectionKey.trim()
|
||||
: payload.conflictDetectionKey,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyPageBodyCommand(input: ApplyPageBodyCommandInput): Promise<PageBodyPersistedMeta> {
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId,
|
||||
revision: input.revision,
|
||||
conflictDetectionKey: input.conflictDetectionKey,
|
||||
content: input.blocks,
|
||||
editorDocument: undefined,
|
||||
tiptapDocument: undefined,
|
||||
blockCount: Array.isArray(input.blocks) ? input.blocks.length : null,
|
||||
snapshotCapturedAt: new Date().toISOString(),
|
||||
});
|
||||
const persistedMeta = input.persistPageBody
|
||||
? await input.persistPageBody(payload)
|
||||
: await persistPageBodyViaRoute(payload, input.fetchImpl ?? fetch);
|
||||
input.applyEditorSnapshot?.(input.blocks);
|
||||
input.onPersistedMetaChange?.(persistedMeta);
|
||||
return persistedMeta;
|
||||
}
|
||||
export {
|
||||
executePageBodyCommand as applyPageBodyCommand,
|
||||
executePageBodySavePayload,
|
||||
type ExecutePageBodyCommandInput as ApplyPageBodyCommandInput,
|
||||
type PageBodyPersistedMeta,
|
||||
type PageBodyPersistedState,
|
||||
} from "@/lib/documents/page-command-client";
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import {
|
||||
executePageBodyCommand,
|
||||
executePageBodySavePayload,
|
||||
executePageHeadCommand,
|
||||
executePageLayoutCommand,
|
||||
} from "./page-command-client";
|
||||
import { buildDocumentSavePayload } from "./save-contract";
|
||||
|
||||
describe("page-command-client", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("标题命令应走统一 page head command", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, meta: { commandName: "page.head.updateTitle" } }),
|
||||
} as Response);
|
||||
|
||||
await executePageHeadCommand({
|
||||
documentId: "doc-1",
|
||||
workspaceId: "ws-1",
|
||||
title: "页面标题",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/documents/title",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(JSON.parse(String(init?.body ?? "{}"))).toEqual({
|
||||
documentId: "doc-1",
|
||||
workspaceId: "ws-1",
|
||||
title: "页面标题",
|
||||
commandName: "page.head.updateTitle",
|
||||
});
|
||||
});
|
||||
|
||||
it("页面设置命令应走统一 page layout command", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, meta: { commandName: "page.layout.updateOptions" } }),
|
||||
} as Response);
|
||||
|
||||
await executePageLayoutCommand({
|
||||
documentId: "doc-1",
|
||||
workspaceId: "ws-1",
|
||||
pageOptions: { wideLayout: true },
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/documents/options",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(JSON.parse(String(init?.body ?? "{}"))).toEqual({
|
||||
documentId: "doc-1",
|
||||
workspaceId: "ws-1",
|
||||
options: { wideLayout: true },
|
||||
commandName: "page.layout.updateOptions",
|
||||
});
|
||||
});
|
||||
|
||||
it("正文保存 payload 应走统一 page body command", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, revision: 5, conflictDetectionKey: "doc-1:5" }),
|
||||
} as Response);
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: "doc-1",
|
||||
workspaceId: "ws-1",
|
||||
revision: 4,
|
||||
conflictDetectionKey: "doc-1:4",
|
||||
content: [{ id: "block_1", type: "paragraph", content: "正文" }] as Json,
|
||||
blockCount: 1,
|
||||
});
|
||||
|
||||
await expect(executePageBodySavePayload(payload)).resolves.toEqual({
|
||||
revision: 5,
|
||||
conflictDetectionKey: "doc-1:5",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/documents/save",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("高层正文命令应在保存成功后再回显快照并回传元信息", async () => {
|
||||
const applyEditorSnapshot = vi.fn();
|
||||
const onPersistedMetaChange = vi.fn();
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true, revision: 9, conflictDetectionKey: "doc-1:9" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const blocks = [{ id: "block_1", type: "paragraph", content: "统一正文" }] as Json;
|
||||
|
||||
await expect(
|
||||
executePageBodyCommand({
|
||||
documentId: "doc-1",
|
||||
workspaceId: "ws-1",
|
||||
revision: 8,
|
||||
conflictDetectionKey: "doc-1:8",
|
||||
blocks,
|
||||
applyEditorSnapshot,
|
||||
onPersistedMetaChange,
|
||||
fetchImpl,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
revision: 9,
|
||||
conflictDetectionKey: "doc-1:9",
|
||||
});
|
||||
|
||||
expect(applyEditorSnapshot).toHaveBeenCalledWith(blocks);
|
||||
expect(onPersistedMetaChange).toHaveBeenCalledWith({
|
||||
revision: 9,
|
||||
conflictDetectionKey: "doc-1:9",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { PageLayoutCommandInput, PageTitleCommandInput } from "@/lib/documents/page-command-contract";
|
||||
import { PAGE_COMMAND_NAMES } from "@/lib/documents/page-command-contract";
|
||||
import { buildDocumentSavePayload, type DocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
import type { Json } from "@/types/supabase";
|
||||
|
||||
export type PageCommandMeta = {
|
||||
requestId?: string;
|
||||
traceId?: string;
|
||||
commandId?: string;
|
||||
commandName?: string;
|
||||
};
|
||||
|
||||
type PageCommandErrorPayload = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type PageHeadCommandResult = {
|
||||
ok: true;
|
||||
meta?: PageCommandMeta;
|
||||
};
|
||||
|
||||
export type PageLayoutCommandResult = {
|
||||
ok: true;
|
||||
meta?: PageCommandMeta;
|
||||
};
|
||||
|
||||
export type PageBodyPersistedMeta = {
|
||||
revision: number | null;
|
||||
conflictDetectionKey: string | null;
|
||||
};
|
||||
|
||||
export type PageBodyPersistedState = PageBodyPersistedMeta & {
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
export type ExecutePageBodyCommandInput = {
|
||||
documentId: string;
|
||||
workspaceId: string | null;
|
||||
revision: number | null;
|
||||
conflictDetectionKey: string | null;
|
||||
blocks: Json;
|
||||
applyEditorSnapshot?: (blocks: Json) => void;
|
||||
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
|
||||
fetchImpl?: typeof fetch;
|
||||
persistPageBody?: (payload: DocumentSavePayload) => Promise<PageBodyPersistedMeta>;
|
||||
};
|
||||
|
||||
async function postPageCommand<TResult>(
|
||||
path: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<TResult> {
|
||||
const response = await fetchImpl(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const body = (await response.json().catch(() => null)) as TResult | PageCommandErrorPayload | null;
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
body && typeof body === "object" && "error" in body && typeof body.error === "string"
|
||||
? body.error
|
||||
: fallbackMessage;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return body as TResult;
|
||||
}
|
||||
|
||||
export async function executePageHeadCommand(
|
||||
input: PageTitleCommandInput,
|
||||
fetchImpl?: typeof fetch,
|
||||
): Promise<PageHeadCommandResult> {
|
||||
return postPageCommand<PageHeadCommandResult>(
|
||||
"/api/documents/title",
|
||||
{
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
title: input.title,
|
||||
commandName: PAGE_COMMAND_NAMES.updateTitle,
|
||||
},
|
||||
"重命名失败,请稍后再试",
|
||||
fetchImpl,
|
||||
);
|
||||
}
|
||||
|
||||
export async function executePageLayoutCommand(
|
||||
input: PageLayoutCommandInput,
|
||||
fetchImpl?: typeof fetch,
|
||||
): Promise<PageLayoutCommandResult> {
|
||||
return postPageCommand<PageLayoutCommandResult>(
|
||||
"/api/documents/options",
|
||||
{
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
options: input.pageOptions,
|
||||
commandName: PAGE_COMMAND_NAMES.updateLayout,
|
||||
},
|
||||
"更新页面选项失败,请稍后再试",
|
||||
fetchImpl,
|
||||
);
|
||||
}
|
||||
|
||||
export async function executePageBodySavePayload(
|
||||
payload: DocumentSavePayload,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<PageBodyPersistedMeta> {
|
||||
const body = await postPageCommand<{
|
||||
ok?: boolean;
|
||||
revision?: number | null;
|
||||
conflictDetectionKey?: string | null;
|
||||
}>(
|
||||
"/api/documents/save",
|
||||
payload,
|
||||
"页面正文保存失败",
|
||||
fetchImpl,
|
||||
);
|
||||
|
||||
return {
|
||||
revision:
|
||||
typeof body?.revision === "number" && Number.isInteger(body.revision) ? body.revision : payload.revision,
|
||||
conflictDetectionKey:
|
||||
typeof body?.conflictDetectionKey === "string" && body.conflictDetectionKey.trim()
|
||||
? body.conflictDetectionKey.trim()
|
||||
: payload.conflictDetectionKey,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executePageBodyCommand(
|
||||
input: ExecutePageBodyCommandInput,
|
||||
): Promise<PageBodyPersistedMeta> {
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId,
|
||||
revision: input.revision,
|
||||
conflictDetectionKey: input.conflictDetectionKey,
|
||||
content: input.blocks,
|
||||
editorDocument: undefined,
|
||||
tiptapDocument: undefined,
|
||||
blockCount: Array.isArray(input.blocks) ? input.blocks.length : null,
|
||||
snapshotCapturedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const persistedMeta = input.persistPageBody
|
||||
? await input.persistPageBody(payload)
|
||||
: await executePageBodySavePayload(payload, input.fetchImpl ?? fetch);
|
||||
input.applyEditorSnapshot?.(input.blocks);
|
||||
input.onPersistedMetaChange?.(persistedMeta);
|
||||
return persistedMeta;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PageOptionsState } from "@/types/page-options";
|
||||
import {
|
||||
PAGE_OPTION_PANEL_GROUPS,
|
||||
PAGE_OPTION_SEMANTICS,
|
||||
pickLeptosTiptapRuntimePageOptions,
|
||||
} from "@/lib/documents/page-option-semantics";
|
||||
|
||||
const options: PageOptionsState = {
|
||||
wideLayout: true,
|
||||
smallText: true,
|
||||
showHeadingNumbers: false,
|
||||
showToc: true,
|
||||
showStructure: true,
|
||||
protectEditing: true,
|
||||
showWordCount: true,
|
||||
collapseBacklinks: true,
|
||||
pageFont: "song",
|
||||
layoutDensity: "compact",
|
||||
hideChildPages: true,
|
||||
showBlockRefCount: true,
|
||||
embedDefaultBlockId: "block-anchor-1",
|
||||
};
|
||||
|
||||
describe("page-option-semantics", () => {
|
||||
it("应固定页面设置在 inspector 中的分组,而不是让分组散落在 UI 文件里", () => {
|
||||
expect(PAGE_OPTION_PANEL_GROUPS.page).toEqual([
|
||||
"wideLayout",
|
||||
"smallText",
|
||||
"showHeadingNumbers",
|
||||
"showToc",
|
||||
"protectEditing",
|
||||
"showWordCount",
|
||||
]);
|
||||
expect(PAGE_OPTION_PANEL_GROUPS.custom).toEqual([
|
||||
"collapseBacklinks",
|
||||
"hideChildPages",
|
||||
"showBlockRefCount",
|
||||
]);
|
||||
});
|
||||
|
||||
it("应明确 page options 的运行时语义归属", () => {
|
||||
expect(PAGE_OPTION_SEMANTICS.wideLayout.surfaces).toEqual([
|
||||
"page_shell_layout",
|
||||
"editor_runtime",
|
||||
]);
|
||||
expect(PAGE_OPTION_SEMANTICS.showToc.surfaces).toEqual(["read_view"]);
|
||||
expect(PAGE_OPTION_SEMANTICS.protectEditing.runtimeSupport).toBe("planned");
|
||||
expect(PAGE_OPTION_SEMANTICS.showBlockRefCount.runtimeSupport).toBe("planned");
|
||||
expect(PAGE_OPTION_SEMANTICS.embedDefaultBlockId.runtimeSupport).toBe("wired");
|
||||
});
|
||||
|
||||
it("应只把已经正式接通的 runtime 选项送入 leptos-tiptap island payload", () => {
|
||||
expect(pickLeptosTiptapRuntimePageOptions(options)).toEqual({
|
||||
wideLayout: true,
|
||||
smallText: true,
|
||||
layoutDensity: "compact",
|
||||
showHeadingNumbers: false,
|
||||
embedDefaultBlockId: "block-anchor-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import type {
|
||||
BooleanPageOptionKey,
|
||||
PageLayoutDensity,
|
||||
PageOptionsState,
|
||||
} from "@/types/page-options";
|
||||
|
||||
export type PageOptionSurface =
|
||||
| "page_shell_layout"
|
||||
| "read_view"
|
||||
| "editor_runtime"
|
||||
| "inspector_only";
|
||||
|
||||
export type PageOptionRuntimeSupport = "wired" | "planned" | "ui_only";
|
||||
|
||||
export type PageOptionSemanticDescriptor = {
|
||||
surfaces: PageOptionSurface[];
|
||||
runtimeSupport: PageOptionRuntimeSupport;
|
||||
};
|
||||
|
||||
export const PAGE_OPTION_PANEL_GROUPS: {
|
||||
page: BooleanPageOptionKey[];
|
||||
custom: BooleanPageOptionKey[];
|
||||
} = {
|
||||
page: [
|
||||
"wideLayout",
|
||||
"smallText",
|
||||
"showHeadingNumbers",
|
||||
"showToc",
|
||||
"protectEditing",
|
||||
"showWordCount",
|
||||
],
|
||||
custom: ["collapseBacklinks", "hideChildPages", "showBlockRefCount"],
|
||||
};
|
||||
|
||||
export const PAGE_OPTION_SEMANTICS: Record<
|
||||
BooleanPageOptionKey | "pageFont" | "layoutDensity" | "showStructure" | "embedDefaultBlockId",
|
||||
PageOptionSemanticDescriptor
|
||||
> = {
|
||||
wideLayout: {
|
||||
surfaces: ["page_shell_layout", "editor_runtime"],
|
||||
runtimeSupport: "wired",
|
||||
},
|
||||
smallText: {
|
||||
surfaces: ["page_shell_layout", "editor_runtime"],
|
||||
runtimeSupport: "wired",
|
||||
},
|
||||
showHeadingNumbers: {
|
||||
surfaces: ["read_view", "editor_runtime"],
|
||||
runtimeSupport: "wired",
|
||||
},
|
||||
showToc: {
|
||||
surfaces: ["read_view"],
|
||||
runtimeSupport: "wired",
|
||||
},
|
||||
showStructure: {
|
||||
surfaces: ["read_view"],
|
||||
runtimeSupport: "ui_only",
|
||||
},
|
||||
protectEditing: {
|
||||
surfaces: ["page_shell_layout", "editor_runtime"],
|
||||
runtimeSupport: "planned",
|
||||
},
|
||||
showWordCount: {
|
||||
surfaces: ["inspector_only"],
|
||||
runtimeSupport: "wired",
|
||||
},
|
||||
collapseBacklinks: {
|
||||
surfaces: ["page_shell_layout"],
|
||||
runtimeSupport: "wired",
|
||||
},
|
||||
pageFont: {
|
||||
surfaces: ["page_shell_layout"],
|
||||
runtimeSupport: "wired",
|
||||
},
|
||||
layoutDensity: {
|
||||
surfaces: ["page_shell_layout", "editor_runtime"],
|
||||
runtimeSupport: "wired",
|
||||
},
|
||||
hideChildPages: {
|
||||
surfaces: ["page_shell_layout", "read_view"],
|
||||
runtimeSupport: "wired",
|
||||
},
|
||||
showBlockRefCount: {
|
||||
surfaces: ["editor_runtime", "inspector_only"],
|
||||
runtimeSupport: "planned",
|
||||
},
|
||||
embedDefaultBlockId: {
|
||||
surfaces: ["editor_runtime"],
|
||||
runtimeSupport: "wired",
|
||||
},
|
||||
};
|
||||
|
||||
export type LeptosTiptapRuntimePageOptions = {
|
||||
wideLayout: boolean;
|
||||
smallText: boolean;
|
||||
layoutDensity: PageLayoutDensity;
|
||||
showHeadingNumbers: boolean;
|
||||
embedDefaultBlockId: string | null;
|
||||
};
|
||||
|
||||
export function pickLeptosTiptapRuntimePageOptions(
|
||||
pageOptions: PageOptionsState,
|
||||
): LeptosTiptapRuntimePageOptions {
|
||||
return {
|
||||
wideLayout: pageOptions.wideLayout,
|
||||
smallText: pageOptions.smallText,
|
||||
layoutDensity: pageOptions.layoutDensity,
|
||||
showHeadingNumbers: pageOptions.showHeadingNumbers,
|
||||
embedDefaultBlockId: pageOptions.embedDefaultBlockId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
|
||||
vi.mock("@/lib/auth/authContext", () => ({
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
requireAuthContext: vi.fn(async () => ({
|
||||
userId: "user_1",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-utils", () => ({
|
||||
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
|
||||
message,
|
||||
status,
|
||||
details,
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
buildDocumentCommandEnvelope,
|
||||
type BridgeContext,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
|
||||
|
||||
vi.mock("@/lib/convex/route", () => ({
|
||||
getAuthedConvexClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||
recordBridgeCommandArtifacts: vi.fn(),
|
||||
recordBridgeCommandFailureArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
resolveRustBridgeCommandPlan: vi.fn(),
|
||||
executeRustBridgeMutationTransport: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockContext: BridgeContext = {
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user_1",
|
||||
sessionId: "sess_1",
|
||||
},
|
||||
source: {
|
||||
channel: "next-route",
|
||||
client: "vitest",
|
||||
},
|
||||
tenantId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: "idem_1",
|
||||
validateOnly: false,
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
describe("page-write-command-adapter", () => {
|
||||
it("标题命令应走 rust bridge transport", async () => {
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
} = await import("@/lib/documents/rust-runtime");
|
||||
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: { userId: "user_1" },
|
||||
client: { mutation: vi.fn() } as unknown as ConvexHttpClient,
|
||||
});
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "page.head.updateTitle",
|
||||
commandId: "cmd_title_1",
|
||||
functionName: "documents:updateTitle",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{\"kind\":\"command\"}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
title: "新标题",
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({ ok: true });
|
||||
|
||||
const result = await executePageWriteBridgeCommand({
|
||||
context: mockContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "page.head.updateTitle",
|
||||
payload: {
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
title: "新标题",
|
||||
},
|
||||
context: mockContext,
|
||||
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "page.head.updateTitle",
|
||||
}),
|
||||
});
|
||||
expect(result.commandName).toBe("page.head.updateTitle");
|
||||
expect(result.revision).toBeNull();
|
||||
expect(result.conflictDetectionKey).toBeNull();
|
||||
});
|
||||
|
||||
it("页面设置命令应走 bridge mutation request", async () => {
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: { userId: "user_1" },
|
||||
client: { mutation } as unknown as ConvexHttpClient,
|
||||
});
|
||||
|
||||
const result = await executePageWriteBridgeCommand({
|
||||
context: mockContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "page.layout.updateOptions",
|
||||
payload: {
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
options: {
|
||||
showToc: true,
|
||||
layoutDensity: "compact",
|
||||
embedDefaultBlockId: null,
|
||||
},
|
||||
},
|
||||
context: mockContext,
|
||||
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(result.commandName).toBe("page.layout.updateOptions");
|
||||
expect(result.revision).toBeNull();
|
||||
expect(result.conflictDetectionKey).toBeNull();
|
||||
});
|
||||
|
||||
it("正文保存命令应返回 revision 与 conflictDetectionKey", async () => {
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
} = await import("@/lib/documents/rust-runtime");
|
||||
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: { userId: "user_1" },
|
||||
client: { mutation: vi.fn() } as unknown as ConvexHttpClient,
|
||||
});
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "page.body.save",
|
||||
commandId: "cmd_save_1",
|
||||
functionName: "documents:updateContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{\"kind\":\"command\"}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
content: [{ id: "block_1" }],
|
||||
expectedRevision: 7,
|
||||
conflictDetectionKey: "conflict_1",
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||
revision: 8,
|
||||
conflict_detection_key: "conflict_2",
|
||||
});
|
||||
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
revision: 7,
|
||||
content: [{ id: "block_1" }],
|
||||
conflictDetectionKey: "conflict_1",
|
||||
});
|
||||
|
||||
const result = await executePageWriteBridgeCommand({
|
||||
context: mockContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "page.body.save",
|
||||
payload,
|
||||
context: mockContext,
|
||||
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.commandName).toBe("page.body.save");
|
||||
expect(result.revision).toBe(8);
|
||||
expect(result.conflictDetectionKey).toBe("conflict_2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
buildDocumentBridgeMutationRequest,
|
||||
executeDocumentBridgeMutationRequest,
|
||||
type BridgeContext,
|
||||
type CommandEnvelope,
|
||||
DocumentBridgeError,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import type { DocumentOptionsUpdatePayload, DocumentTitleUpdatePayload } from "@/lib/documents/metadata-command-adapter";
|
||||
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
resolveRustBridgeCommandPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
type PageWritePayload =
|
||||
| DocumentTitleUpdatePayload
|
||||
| DocumentOptionsUpdatePayload
|
||||
| DocumentSavePayload;
|
||||
|
||||
type MetadataMutationArgs = Record<string, unknown>;
|
||||
|
||||
type PageWriteAdapter<TPayload> = {
|
||||
kind: "rust_transport" | "convex_mutation";
|
||||
convexMutation?: unknown;
|
||||
mapConvexArgs?: (payload: TPayload) => MetadataMutationArgs;
|
||||
};
|
||||
|
||||
export type PageWriteCommandExecutionResult = {
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
commandId: string;
|
||||
commandName: string;
|
||||
revision: number | null;
|
||||
conflictDetectionKey: string | null;
|
||||
};
|
||||
|
||||
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
|
||||
return {
|
||||
id: payload.documentId,
|
||||
options: {
|
||||
wideLayout: payload.options.wideLayout,
|
||||
smallText: payload.options.smallText,
|
||||
showHeadingNumbers: payload.options.showHeadingNumbers,
|
||||
showToc: payload.options.showToc,
|
||||
showStructure: payload.options.showStructure,
|
||||
protectEditing: payload.options.protectEditing,
|
||||
showWordCount: payload.options.showWordCount,
|
||||
collapseBacklinks: payload.options.collapseBacklinks,
|
||||
pageFont: payload.options.pageFont,
|
||||
layoutDensity: payload.options.layoutDensity,
|
||||
hideChildPages: payload.options.hideChildPages,
|
||||
showBlockRefCount: payload.options.showBlockRefCount,
|
||||
embedDefaultBlockId:
|
||||
typeof payload.options.embedDefaultBlockId === "string" ? payload.options.embedDefaultBlockId : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const pageWriteAdapters: Record<string, PageWriteAdapter<unknown>> = {
|
||||
"page.head.updateTitle": {
|
||||
kind: "rust_transport",
|
||||
},
|
||||
"page.layout.updateOptions": {
|
||||
kind: "convex_mutation",
|
||||
convexMutation: api.documents.updateOptions,
|
||||
mapConvexArgs: mapDocumentOptionsToConvexArgs,
|
||||
},
|
||||
"page.body.save": {
|
||||
kind: "rust_transport",
|
||||
},
|
||||
};
|
||||
|
||||
function getPageWriteAdapter<TPayload>(commandName: string): PageWriteAdapter<TPayload> {
|
||||
const adapter = pageWriteAdapters[commandName];
|
||||
if (!adapter) {
|
||||
throw new Error(`未注册页面写命令适配器: ${commandName}`);
|
||||
}
|
||||
return adapter as PageWriteAdapter<TPayload>;
|
||||
}
|
||||
|
||||
function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecutionResult, "revision" | "conflictDetectionKey"> {
|
||||
const record = result && typeof result === "object" ? (result as Record<string, unknown>) : null;
|
||||
return {
|
||||
revision:
|
||||
typeof record?.revision === "number" && Number.isInteger(record.revision)
|
||||
? record.revision
|
||||
: null,
|
||||
conflictDetectionKey:
|
||||
typeof record?.conflict_detection_key === "string" && record.conflict_detection_key.trim()
|
||||
? record.conflict_detection_key.trim()
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executePageWriteBridgeCommand<TPayload extends PageWritePayload>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
}): Promise<PageWriteCommandExecutionResult> {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const adapter = getPageWriteAdapter<TPayload>(input.envelope.name);
|
||||
|
||||
try {
|
||||
if (adapter.kind === "rust_transport") {
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
const transportResult = await executeRustBridgeMutationTransport({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
const persistedMeta = normalizePersistedMeta(transportResult);
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
|
||||
return {
|
||||
requestId: input.context.requestId,
|
||||
traceId: input.context.traceId,
|
||||
commandId: input.envelope.commandId,
|
||||
commandName: input.envelope.name,
|
||||
revision: persistedMeta.revision,
|
||||
conflictDetectionKey: persistedMeta.conflictDetectionKey,
|
||||
};
|
||||
}
|
||||
|
||||
const mutationRequest = buildDocumentBridgeMutationRequest({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
mapConvexArgs: adapter.mapConvexArgs!,
|
||||
});
|
||||
|
||||
await executeDocumentBridgeMutationRequest({
|
||||
client,
|
||||
mutation: adapter.convexMutation!,
|
||||
request: mutationRequest,
|
||||
});
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
|
||||
return {
|
||||
requestId: input.context.requestId,
|
||||
traceId: input.context.traceId,
|
||||
commandId: input.envelope.commandId,
|
||||
commandName: input.envelope.name,
|
||||
revision: null,
|
||||
conflictDetectionKey: null,
|
||||
};
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
client,
|
||||
error,
|
||||
});
|
||||
if (
|
||||
input.envelope.name === "page.body.save" &&
|
||||
error instanceof Error &&
|
||||
/正文(内容已变更|冲突检测失败)/.test(error.message)
|
||||
) {
|
||||
throw new DocumentBridgeError(error.message, 409, "REJECTED", {
|
||||
reason: "content_conflict",
|
||||
revision: (input.envelope.payload as DocumentSavePayload).revision,
|
||||
conflictDetectionKey: (input.envelope.payload as DocumentSavePayload).conflictDetectionKey,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function shouldUseBuiltBridgeRuntimeBinary(input: {
|
||||
builtBinaryMtimeMs: number | null;
|
||||
latestSourceMtimeMs: number | null;
|
||||
}): boolean {
|
||||
if (typeof input.builtBinaryMtimeMs !== "number" || !Number.isFinite(input.builtBinaryMtimeMs)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof input.latestSourceMtimeMs !== "number" || !Number.isFinite(input.latestSourceMtimeMs)) {
|
||||
return true;
|
||||
}
|
||||
return input.builtBinaryMtimeMs >= input.latestSourceMtimeMs;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
let runtimeSelection: Record<string, unknown> = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
runtimeSelection = (await import("@/lib/documents/rust-runtime-selection")) as Record<string, unknown>;
|
||||
} catch {
|
||||
runtimeSelection = {};
|
||||
}
|
||||
});
|
||||
|
||||
describe("shouldUseBuiltBridgeRuntimeBinary", () => {
|
||||
it("当源码比已编译二进制更新时应放弃旧二进制", () => {
|
||||
const decide = runtimeSelection.shouldUseBuiltBridgeRuntimeBinary;
|
||||
expect(typeof decide).toBe("function");
|
||||
if (typeof decide !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(
|
||||
decide({
|
||||
builtBinaryMtimeMs: 100,
|
||||
latestSourceMtimeMs: 200,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("当二进制不旧于源码时仍可直接复用", () => {
|
||||
const decide = runtimeSelection.shouldUseBuiltBridgeRuntimeBinary;
|
||||
expect(typeof decide).toBe("function");
|
||||
if (typeof decide !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(
|
||||
decide({
|
||||
builtBinaryMtimeMs: 300,
|
||||
latestSourceMtimeMs: 200,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { access } from "node:fs/promises";
|
||||
import { access, readdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import { api } from "@/lib/convex/api";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type CommandEnvelope,
|
||||
type QueryEnvelope,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { shouldUseBuiltBridgeRuntimeBinary } from "@/lib/documents/rust-runtime-selection";
|
||||
|
||||
export type RustRuntimeExecutedQuery<TResult = unknown> = {
|
||||
ok: true;
|
||||
@@ -140,6 +141,68 @@ async function resolveRepoRoot() {
|
||||
throw new DocumentBridgeError("未找到 mnote 仓库根目录", 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
|
||||
async function readLatestMtimeMs(targetPath: string): Promise<number | null> {
|
||||
try {
|
||||
const stats = await stat(targetPath);
|
||||
if (stats.isFile()) {
|
||||
return stats.mtimeMs;
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = await readdir(targetPath, { withFileTypes: true });
|
||||
const nestedTimes = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => !entry.name.startsWith("."))
|
||||
.map((entry) => readLatestMtimeMs(path.join(targetPath, entry.name))),
|
||||
);
|
||||
const latestChild = nestedTimes.reduce<number | null>(
|
||||
(current, next) => {
|
||||
if (typeof next !== "number" || !Number.isFinite(next)) {
|
||||
return current;
|
||||
}
|
||||
if (typeof current !== "number" || !Number.isFinite(current)) {
|
||||
return next;
|
||||
}
|
||||
return Math.max(current, next);
|
||||
},
|
||||
null,
|
||||
);
|
||||
return latestChild == null ? stats.mtimeMs : Math.max(stats.mtimeMs, latestChild);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readRuntimeSourceLatestMtimeMs(repoRoot: string) {
|
||||
const sourceRoots = [
|
||||
path.join(repoRoot, "rust", "Cargo.toml"),
|
||||
path.join(repoRoot, "rust", "Cargo.lock"),
|
||||
path.join(repoRoot, "rust", "crates", "bridge-runtime", "Cargo.toml"),
|
||||
path.join(repoRoot, "rust", "crates", "bridge-runtime", "src"),
|
||||
path.join(repoRoot, "rust", "crates", "storage-convex-bridge", "Cargo.toml"),
|
||||
path.join(repoRoot, "rust", "crates", "storage-convex-bridge", "src"),
|
||||
path.join(repoRoot, "rust", "crates", "core-protocol", "Cargo.toml"),
|
||||
path.join(repoRoot, "rust", "crates", "core-protocol", "src"),
|
||||
];
|
||||
|
||||
const mtimes = await Promise.all(sourceRoots.map((targetPath) => readLatestMtimeMs(targetPath)));
|
||||
return mtimes.reduce<number | null>(
|
||||
(current, next) => {
|
||||
if (typeof next !== "number" || !Number.isFinite(next)) {
|
||||
return current;
|
||||
}
|
||||
if (typeof current !== "number" || !Number.isFinite(current)) {
|
||||
return next;
|
||||
}
|
||||
return Math.max(current, next);
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveRuntimeInvocation(): Promise<RuntimeInvocation> {
|
||||
const explicitBin = process.env.MNOTE_RUST_BRIDGE_BIN?.trim();
|
||||
if (explicitBin) {
|
||||
@@ -152,10 +215,22 @@ async function resolveRuntimeInvocation(): Promise<RuntimeInvocation> {
|
||||
const repoRoot = await resolveRepoRoot();
|
||||
const builtBinary = path.join(repoRoot, "rust", "target", "debug", "bridge-runtime");
|
||||
if (await pathExists(builtBinary)) {
|
||||
return {
|
||||
command: builtBinary,
|
||||
args: [],
|
||||
};
|
||||
const [builtBinaryMtimeMs, latestSourceMtimeMs] = await Promise.all([
|
||||
readLatestMtimeMs(builtBinary),
|
||||
readRuntimeSourceLatestMtimeMs(repoRoot),
|
||||
]);
|
||||
|
||||
if (
|
||||
shouldUseBuiltBridgeRuntimeBinary({
|
||||
builtBinaryMtimeMs,
|
||||
latestSourceMtimeMs,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
command: builtBinary,
|
||||
args: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,12 @@ import type {
|
||||
PageLayoutCommandInput,
|
||||
PageTitleCommandInput,
|
||||
} from "@/lib/documents/page-command-contract";
|
||||
import {
|
||||
executePageHeadCommand,
|
||||
executePageLayoutCommand,
|
||||
type PageHeadCommandResult,
|
||||
type PageLayoutCommandResult,
|
||||
} from "@/lib/documents/page-command-client";
|
||||
import type { PageOptionsState } from "@/types/page-options";
|
||||
|
||||
type DocumentCommandMeta = {
|
||||
@@ -170,23 +176,19 @@ export async function renameDocumentCommand(input: RenameDocumentInput): Promise
|
||||
|
||||
export async function updatePageTitleCommand(
|
||||
input: PageTitleCommandInput,
|
||||
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
|
||||
return renameDocumentCommand(input);
|
||||
): Promise<PageHeadCommandResult> {
|
||||
return executePageHeadCommand(input);
|
||||
}
|
||||
|
||||
export async function updatePageOptionsCommand(
|
||||
input: PageLayoutCommandInput | UpdatePageOptionsInput,
|
||||
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
|
||||
): Promise<PageLayoutCommandResult> {
|
||||
const pageOptions = "pageOptions" in input ? input.pageOptions : {};
|
||||
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
|
||||
"/api/documents/options",
|
||||
{
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
options: pageOptions,
|
||||
},
|
||||
"更新页面选项失败,请稍后再试",
|
||||
);
|
||||
return executePageLayoutCommand({
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
pageOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
|
||||
|
||||
Reference in New Issue
Block a user