2026-04-14 13:22:29 +08:00
|
|
|
import { randomUUID } from "crypto";
|
2026-04-15 03:06:29 +08:00
|
|
|
import type { ConvexHttpClient } from "convex/browser";
|
2026-04-14 13:22:29 +08:00
|
|
|
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
|
|
|
|
import { apiErrorResponse } from "@/lib/api-utils";
|
|
|
|
|
import type { PageOptionsState } from "@/types/page-options";
|
|
|
|
|
|
|
|
|
|
export type BridgeActor = {
|
|
|
|
|
actorType: string;
|
|
|
|
|
actorId: string;
|
|
|
|
|
sessionId: string | null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type BridgeSource = {
|
|
|
|
|
channel: string;
|
|
|
|
|
client: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type BridgeTarget = {
|
|
|
|
|
workspaceId?: string | null;
|
|
|
|
|
pageId?: string | null;
|
|
|
|
|
blockId?: string | null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type BridgeRequestMeta = {
|
|
|
|
|
idempotencyKey: string | null;
|
|
|
|
|
validateOnly: boolean;
|
|
|
|
|
dryRun: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type BridgeContext = {
|
|
|
|
|
deploymentId: string | null;
|
|
|
|
|
projectId: string | null;
|
|
|
|
|
workspaceId: string | null;
|
|
|
|
|
requestId: string;
|
|
|
|
|
traceId: string;
|
|
|
|
|
actor: BridgeActor;
|
|
|
|
|
source: BridgeSource;
|
|
|
|
|
tenantId: string | null;
|
|
|
|
|
authToken: string | null;
|
|
|
|
|
idempotencyKey: string | null;
|
|
|
|
|
validateOnly: boolean;
|
|
|
|
|
dryRun: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type BridgeErrorCode =
|
|
|
|
|
| "VALIDATION_ERROR"
|
|
|
|
|
| "UNAUTHORIZED"
|
|
|
|
|
| "FORBIDDEN"
|
|
|
|
|
| "NOT_FOUND"
|
|
|
|
|
| "TRANSPORT_ERROR"
|
|
|
|
|
| "REJECTED";
|
|
|
|
|
|
|
|
|
|
export class DocumentBridgeError extends Error {
|
|
|
|
|
constructor(
|
|
|
|
|
message: string,
|
|
|
|
|
public readonly status: number,
|
|
|
|
|
public readonly code: BridgeErrorCode,
|
|
|
|
|
public readonly details?: unknown,
|
|
|
|
|
) {
|
|
|
|
|
super(message);
|
|
|
|
|
this.name = "DocumentBridgeError";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type CommandEnvelope<T> = {
|
|
|
|
|
name: string;
|
|
|
|
|
commandId: string;
|
|
|
|
|
idempotencyKey: string | null;
|
|
|
|
|
actor: BridgeActor;
|
|
|
|
|
source: BridgeSource;
|
|
|
|
|
target: BridgeTarget | null;
|
|
|
|
|
payload: T;
|
|
|
|
|
reason: string | null;
|
|
|
|
|
refs: string[];
|
|
|
|
|
dryRun: boolean;
|
|
|
|
|
validateOnly: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type QueryEnvelope<T> = {
|
|
|
|
|
name: string;
|
|
|
|
|
payload: T;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-15 03:06:29 +08:00
|
|
|
export type DocumentBridgeMutationRequest<
|
|
|
|
|
TArgs extends Record<string, unknown> = Record<string, unknown>,
|
|
|
|
|
> = {
|
|
|
|
|
functionName: string;
|
|
|
|
|
deploymentId: string | null;
|
|
|
|
|
projectId: string | null;
|
|
|
|
|
workspaceId: string | null;
|
|
|
|
|
requestId: string;
|
|
|
|
|
traceId: string;
|
|
|
|
|
idempotencyKey: string | null;
|
|
|
|
|
actorId: string;
|
|
|
|
|
payloadJson: string;
|
|
|
|
|
args: TArgs;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-15 20:01:12 +08:00
|
|
|
export type DocumentBridgeQueryRequest<
|
|
|
|
|
TArgs extends Record<string, unknown> = Record<string, unknown>,
|
|
|
|
|
> = {
|
|
|
|
|
functionName: string;
|
|
|
|
|
deploymentId: string | null;
|
|
|
|
|
projectId: string | null;
|
|
|
|
|
workspaceId: string | null;
|
|
|
|
|
requestId: string;
|
|
|
|
|
traceId: string;
|
|
|
|
|
actorId: string;
|
|
|
|
|
payloadJson: string;
|
|
|
|
|
args: TArgs;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const DOCUMENT_BRIDGE_QUERY_FUNCTIONS = {
|
|
|
|
|
"documents.content.get": "documents:getContent",
|
|
|
|
|
"documents.meta.get": "documents:getMeta",
|
|
|
|
|
"blocks.get": "documents:getContent",
|
|
|
|
|
} as const satisfies Record<string, string>;
|
|
|
|
|
|
2026-04-15 03:06:29 +08:00
|
|
|
const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
|
2026-04-15 20:01:12 +08:00
|
|
|
"documents.create": "documents:createWithParentReference",
|
|
|
|
|
"documents.move": "documents:move",
|
|
|
|
|
"documents.delete": "documents:softDelete",
|
|
|
|
|
"documents.restore": "documents:restore",
|
|
|
|
|
"documents.duplicate": "documents:duplicateWithMindmaps",
|
|
|
|
|
"documents.copy_tree": "documents:copyTree",
|
2026-04-16 15:24:37 +08:00
|
|
|
"documents.template": "documents:setTemplate",
|
|
|
|
|
"documents.emptyTrashByWorkspace": "documents:emptyTrashByWorkspace",
|
|
|
|
|
"documents.purge": "documents:purge",
|
2026-04-18 05:43:49 +08:00
|
|
|
"documents.embed": "documents:updateContent",
|
2026-04-15 20:01:12 +08:00
|
|
|
"blocks.patch": "documents:updateContent",
|
|
|
|
|
"blocks.move": "documents:updateContent",
|
|
|
|
|
"blocks.embed": "documents:updateContent",
|
2026-04-15 03:06:29 +08:00
|
|
|
"documents.title.update": "documents:updateTitle",
|
|
|
|
|
"documents.stats.update": "documents:updateStats",
|
|
|
|
|
"documents.options.update": "documents:updateOptions",
|
|
|
|
|
"documents.save": "documents:updateContent",
|
2026-04-16 15:24:37 +08:00
|
|
|
"mindmaps.delete": "mindmaps:softDelete",
|
|
|
|
|
"mindmaps.restore": "mindmaps:restore",
|
|
|
|
|
"mindmaps.purge": "mindmaps:purge",
|
|
|
|
|
"mindmaps.emptyTrashByWorkspace": "mindmaps:emptyTrashByWorkspace",
|
2026-04-15 03:34:52 +08:00
|
|
|
"media.assets.replace_storage": "mediaAssets:replaceStorageFromUpload",
|
2026-04-15 03:06:29 +08:00
|
|
|
} as const satisfies Record<string, string>;
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
function readHeaderValue(headerList: Headers, ...candidates: string[]): string | null {
|
|
|
|
|
for (const candidate of candidates) {
|
|
|
|
|
const value = headerList.get(candidate);
|
|
|
|
|
if (typeof value === "string" && value.trim()) {
|
|
|
|
|
return value.trim();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toBooleanFlag(raw: string | null): boolean {
|
|
|
|
|
if (!raw) return false;
|
|
|
|
|
return raw === "1" || raw.toLowerCase() === "true";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeFallbackId(prefix: string): string {
|
|
|
|
|
return `${prefix}_${randomUUID()}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function buildDocumentBridgeContext(input: {
|
|
|
|
|
request: Request;
|
|
|
|
|
workspaceId?: string | null;
|
|
|
|
|
idempotencyKey?: string | null;
|
|
|
|
|
validateOnly?: boolean;
|
|
|
|
|
dryRun?: boolean;
|
|
|
|
|
}): Promise<BridgeContext> {
|
|
|
|
|
let auth;
|
|
|
|
|
try {
|
|
|
|
|
auth = await requireAuthContext();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof HttpError) {
|
|
|
|
|
throw new DocumentBridgeError(error.message || "未登录", error.status, "UNAUTHORIZED");
|
|
|
|
|
}
|
|
|
|
|
throw new DocumentBridgeError("未登录", 401, "UNAUTHORIZED");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const headerList = input.request.headers;
|
|
|
|
|
const requestId =
|
|
|
|
|
readHeaderValue(headerList, "x-request-id", "x-mnote-request-id") ?? makeFallbackId("req");
|
|
|
|
|
const traceId =
|
|
|
|
|
readHeaderValue(headerList, "x-trace-id", "x-mnote-trace-id", "x-request-id") ?? makeFallbackId("trace");
|
|
|
|
|
const sessionId = readHeaderValue(headerList, "x-session-id", "x-mnote-session-id");
|
|
|
|
|
const sourceChannel = readHeaderValue(headerList, "x-source-channel") ?? "next-route";
|
|
|
|
|
const sourceClient = readHeaderValue(headerList, "x-source-client", "user-agent") ?? "wolai-frontend";
|
|
|
|
|
const deploymentId =
|
|
|
|
|
readHeaderValue(headerList, "x-deployment-id") ?? process.env.VERCEL_DEPLOYMENT_ID ?? null;
|
|
|
|
|
const projectId = readHeaderValue(headerList, "x-project-id") ?? process.env.VERCEL_PROJECT_ID ?? null;
|
|
|
|
|
const tenantId = readHeaderValue(headerList, "x-tenant-id");
|
|
|
|
|
const authToken = readHeaderValue(headerList, "authorization");
|
|
|
|
|
const idempotencyKey =
|
|
|
|
|
input.idempotencyKey ?? readHeaderValue(headerList, "idempotency-key", "x-idempotency-key");
|
|
|
|
|
const validateOnly = input.validateOnly ?? toBooleanFlag(readHeaderValue(headerList, "x-validate-only"));
|
|
|
|
|
const dryRun = input.dryRun ?? toBooleanFlag(readHeaderValue(headerList, "x-dry-run"));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
deploymentId,
|
|
|
|
|
projectId,
|
|
|
|
|
workspaceId: input.workspaceId ?? null,
|
|
|
|
|
requestId,
|
|
|
|
|
traceId,
|
|
|
|
|
actor: {
|
|
|
|
|
actorType: "user",
|
|
|
|
|
actorId: auth.userId,
|
|
|
|
|
sessionId,
|
|
|
|
|
},
|
|
|
|
|
source: {
|
|
|
|
|
channel: sourceChannel,
|
|
|
|
|
client: sourceClient,
|
|
|
|
|
},
|
|
|
|
|
tenantId,
|
|
|
|
|
authToken,
|
|
|
|
|
idempotencyKey,
|
|
|
|
|
validateOnly,
|
|
|
|
|
dryRun,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 03:34:52 +08:00
|
|
|
export function buildDocumentBridgeContextWithActor(input: {
|
|
|
|
|
request: Request;
|
|
|
|
|
actor: BridgeActor;
|
|
|
|
|
workspaceId?: string | null;
|
|
|
|
|
idempotencyKey?: string | null;
|
|
|
|
|
validateOnly?: boolean;
|
|
|
|
|
dryRun?: boolean;
|
|
|
|
|
source?: Partial<BridgeSource>;
|
|
|
|
|
authToken?: string | null;
|
|
|
|
|
}): BridgeContext {
|
|
|
|
|
const headerList = input.request.headers;
|
|
|
|
|
const requestId =
|
|
|
|
|
readHeaderValue(headerList, "x-request-id", "x-mnote-request-id") ?? makeFallbackId("req");
|
|
|
|
|
const traceId =
|
|
|
|
|
readHeaderValue(headerList, "x-trace-id", "x-mnote-trace-id", "x-request-id") ?? makeFallbackId("trace");
|
|
|
|
|
const sourceChannel = input.source?.channel?.trim() || readHeaderValue(headerList, "x-source-channel") || "next-route";
|
|
|
|
|
const sourceClient =
|
|
|
|
|
input.source?.client?.trim() ||
|
|
|
|
|
readHeaderValue(headerList, "x-source-client", "user-agent") ||
|
|
|
|
|
"wolai-frontend";
|
|
|
|
|
const deploymentId =
|
|
|
|
|
readHeaderValue(headerList, "x-deployment-id") ?? process.env.VERCEL_DEPLOYMENT_ID ?? null;
|
|
|
|
|
const projectId = readHeaderValue(headerList, "x-project-id") ?? process.env.VERCEL_PROJECT_ID ?? null;
|
|
|
|
|
const tenantId = readHeaderValue(headerList, "x-tenant-id");
|
|
|
|
|
const authToken = input.authToken ?? readHeaderValue(headerList, "authorization");
|
|
|
|
|
const idempotencyKey =
|
|
|
|
|
input.idempotencyKey ?? readHeaderValue(headerList, "idempotency-key", "x-idempotency-key");
|
|
|
|
|
const validateOnly = input.validateOnly ?? toBooleanFlag(readHeaderValue(headerList, "x-validate-only"));
|
|
|
|
|
const dryRun = input.dryRun ?? toBooleanFlag(readHeaderValue(headerList, "x-dry-run"));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
deploymentId,
|
|
|
|
|
projectId,
|
|
|
|
|
workspaceId: input.workspaceId ?? null,
|
|
|
|
|
requestId,
|
|
|
|
|
traceId,
|
|
|
|
|
actor: input.actor,
|
|
|
|
|
source: {
|
|
|
|
|
channel: sourceChannel,
|
|
|
|
|
client: sourceClient,
|
|
|
|
|
},
|
|
|
|
|
tenantId,
|
|
|
|
|
authToken,
|
|
|
|
|
idempotencyKey,
|
|
|
|
|
validateOnly,
|
|
|
|
|
dryRun,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
export function buildDocumentCommandEnvelope<T>(input: {
|
|
|
|
|
name: string;
|
|
|
|
|
payload: T;
|
|
|
|
|
context: BridgeContext;
|
|
|
|
|
target?: BridgeTarget | null;
|
|
|
|
|
reason?: string | null;
|
|
|
|
|
refs?: string[];
|
|
|
|
|
}): CommandEnvelope<T> {
|
|
|
|
|
return {
|
|
|
|
|
name: input.name,
|
|
|
|
|
commandId: makeFallbackId("cmd"),
|
|
|
|
|
idempotencyKey: input.context.idempotencyKey,
|
|
|
|
|
actor: input.context.actor,
|
|
|
|
|
source: input.context.source,
|
|
|
|
|
target: input.target ?? null,
|
|
|
|
|
payload: input.payload,
|
|
|
|
|
reason: input.reason ?? null,
|
|
|
|
|
refs: input.refs ?? [],
|
|
|
|
|
dryRun: input.context.dryRun,
|
|
|
|
|
validateOnly: input.context.validateOnly,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildDocumentQueryEnvelope<T>(input: { name: string; payload: T }): QueryEnvelope<T> {
|
|
|
|
|
return {
|
|
|
|
|
name: input.name,
|
|
|
|
|
payload: input.payload,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 03:06:29 +08:00
|
|
|
function getDocumentBridgeMutationFunctionName(commandName: string): string {
|
|
|
|
|
const functionName =
|
|
|
|
|
DOCUMENT_BRIDGE_MUTATION_FUNCTIONS[
|
|
|
|
|
commandName as keyof typeof DOCUMENT_BRIDGE_MUTATION_FUNCTIONS
|
|
|
|
|
];
|
|
|
|
|
if (!functionName) {
|
|
|
|
|
throw new DocumentBridgeError(
|
|
|
|
|
`未注册文档 bridge mutation: ${commandName}`,
|
|
|
|
|
500,
|
|
|
|
|
"TRANSPORT_ERROR",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return functionName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildDocumentCommandPayloadJson(input: {
|
|
|
|
|
context: BridgeContext;
|
|
|
|
|
commandName: string;
|
|
|
|
|
workspaceId: string | null;
|
|
|
|
|
idempotencyKey: string | null;
|
|
|
|
|
}): string {
|
|
|
|
|
return JSON.stringify({
|
|
|
|
|
kind: "command",
|
|
|
|
|
name: input.commandName,
|
|
|
|
|
request_id: input.context.requestId,
|
|
|
|
|
trace_id: input.context.traceId,
|
|
|
|
|
deployment_id: input.context.deploymentId,
|
|
|
|
|
project_id: input.context.projectId,
|
|
|
|
|
workspace_id: input.workspaceId,
|
|
|
|
|
tenant_id: input.context.tenantId,
|
|
|
|
|
idempotency_key: input.idempotencyKey,
|
|
|
|
|
actor: {
|
|
|
|
|
type: input.context.actor.actorType,
|
|
|
|
|
id: input.context.actor.actorId,
|
|
|
|
|
session_id: input.context.actor.sessionId,
|
|
|
|
|
},
|
|
|
|
|
source: {
|
|
|
|
|
channel: input.context.source.channel,
|
|
|
|
|
client: input.context.source.client,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 20:01:12 +08:00
|
|
|
function buildDocumentQueryPayloadJson(input: {
|
|
|
|
|
context: BridgeContext;
|
|
|
|
|
queryName: string;
|
|
|
|
|
workspaceId: string | null;
|
|
|
|
|
}): string {
|
|
|
|
|
return JSON.stringify({
|
|
|
|
|
kind: "query",
|
|
|
|
|
name: input.queryName,
|
|
|
|
|
request_id: input.context.requestId,
|
|
|
|
|
trace_id: input.context.traceId,
|
|
|
|
|
deployment_id: input.context.deploymentId,
|
|
|
|
|
project_id: input.context.projectId,
|
|
|
|
|
workspace_id: input.workspaceId,
|
|
|
|
|
tenant_id: input.context.tenantId,
|
|
|
|
|
actor_id: input.context.actor.actorId,
|
|
|
|
|
source: {
|
|
|
|
|
channel: input.context.source.channel,
|
|
|
|
|
client: input.context.source.client,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 03:06:29 +08:00
|
|
|
export function buildDocumentBridgeMutationRequest<
|
|
|
|
|
TPayload,
|
|
|
|
|
TArgs extends Record<string, unknown>,
|
|
|
|
|
>(input: {
|
|
|
|
|
context: BridgeContext;
|
|
|
|
|
envelope: CommandEnvelope<TPayload>;
|
|
|
|
|
mapConvexArgs: (payload: TPayload) => TArgs;
|
|
|
|
|
}): DocumentBridgeMutationRequest<TArgs> {
|
|
|
|
|
const workspaceId = input.envelope.target?.workspaceId ?? input.context.workspaceId ?? null;
|
|
|
|
|
const idempotencyKey = input.envelope.idempotencyKey ?? input.context.idempotencyKey;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
functionName: getDocumentBridgeMutationFunctionName(input.envelope.name),
|
|
|
|
|
deploymentId: input.context.deploymentId,
|
|
|
|
|
projectId: input.context.projectId,
|
|
|
|
|
workspaceId,
|
|
|
|
|
requestId: input.context.requestId,
|
|
|
|
|
traceId: input.context.traceId,
|
|
|
|
|
idempotencyKey,
|
|
|
|
|
actorId: input.context.actor.actorId,
|
|
|
|
|
payloadJson: buildDocumentCommandPayloadJson({
|
|
|
|
|
context: input.context,
|
|
|
|
|
commandName: input.envelope.name,
|
|
|
|
|
workspaceId,
|
|
|
|
|
idempotencyKey,
|
|
|
|
|
}),
|
|
|
|
|
args: input.mapConvexArgs(input.envelope.payload),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 20:01:12 +08:00
|
|
|
function getDocumentBridgeQueryFunctionName(queryName: string): string {
|
|
|
|
|
const functionName =
|
|
|
|
|
DOCUMENT_BRIDGE_QUERY_FUNCTIONS[
|
|
|
|
|
queryName as keyof typeof DOCUMENT_BRIDGE_QUERY_FUNCTIONS
|
|
|
|
|
];
|
|
|
|
|
if (!functionName) {
|
|
|
|
|
throw new DocumentBridgeError(
|
|
|
|
|
`未注册文档 bridge query: ${queryName}`,
|
|
|
|
|
500,
|
|
|
|
|
"TRANSPORT_ERROR",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return functionName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildDocumentBridgeQueryRequest<
|
|
|
|
|
TPayload,
|
|
|
|
|
TArgs extends Record<string, unknown>,
|
|
|
|
|
>(input: {
|
|
|
|
|
context: BridgeContext;
|
|
|
|
|
envelope: QueryEnvelope<TPayload>;
|
|
|
|
|
mapConvexArgs: (payload: TPayload) => TArgs;
|
|
|
|
|
}): DocumentBridgeQueryRequest<TArgs> {
|
|
|
|
|
const workspaceId = input.context.workspaceId ?? null;
|
|
|
|
|
return {
|
|
|
|
|
functionName: getDocumentBridgeQueryFunctionName(input.envelope.name),
|
|
|
|
|
deploymentId: input.context.deploymentId,
|
|
|
|
|
projectId: input.context.projectId,
|
|
|
|
|
workspaceId,
|
|
|
|
|
requestId: input.context.requestId,
|
|
|
|
|
traceId: input.context.traceId,
|
|
|
|
|
actorId: input.context.actor.actorId,
|
|
|
|
|
payloadJson: buildDocumentQueryPayloadJson({
|
|
|
|
|
context: input.context,
|
|
|
|
|
queryName: input.envelope.name,
|
|
|
|
|
workspaceId,
|
|
|
|
|
}),
|
|
|
|
|
args: input.mapConvexArgs(input.envelope.payload),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 03:06:29 +08:00
|
|
|
export async function executeDocumentBridgeMutationRequest<
|
|
|
|
|
TArgs extends Record<string, unknown>,
|
|
|
|
|
TResult,
|
|
|
|
|
>(input: {
|
|
|
|
|
client: ConvexHttpClient;
|
|
|
|
|
mutation: unknown;
|
|
|
|
|
request: DocumentBridgeMutationRequest<TArgs>;
|
|
|
|
|
}): Promise<TResult> {
|
|
|
|
|
const mutate = input.client.mutation.bind(input.client) as (
|
|
|
|
|
mutation: unknown,
|
|
|
|
|
args: TArgs,
|
|
|
|
|
) => Promise<TResult>;
|
|
|
|
|
return mutate(input.mutation, input.request.args);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
export function assertDocumentId(documentId: string | null | undefined): string {
|
|
|
|
|
const normalized = typeof documentId === "string" ? documentId.trim() : "";
|
|
|
|
|
if (!normalized) {
|
|
|
|
|
throw new DocumentBridgeError("缺少 documentId", 400, "VALIDATION_ERROR", [
|
|
|
|
|
{ field: "documentId", reason: "required" },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function assertTitle(title: string | null | undefined): string {
|
|
|
|
|
if (typeof title !== "string") {
|
|
|
|
|
throw new DocumentBridgeError("缺少 title", 400, "VALIDATION_ERROR", [
|
|
|
|
|
{ field: "title", reason: "required" },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
const normalized = title.trim() || "无标题";
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function assertBlockId(blockId: string | null | undefined): string {
|
|
|
|
|
const normalized = typeof blockId === "string" ? blockId.trim() : "";
|
|
|
|
|
if (!normalized) {
|
|
|
|
|
throw new DocumentBridgeError("缺少 blockId", 400, "VALIDATION_ERROR", [
|
|
|
|
|
{ field: "blockId", reason: "required" },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function assertNextBlock(nextBlock: unknown): asserts nextBlock is Record<string, unknown> {
|
|
|
|
|
if (!nextBlock || typeof nextBlock !== "object" || Array.isArray(nextBlock)) {
|
|
|
|
|
throw new DocumentBridgeError("缺少 nextBlock", 400, "VALIDATION_ERROR", [
|
|
|
|
|
{ field: "nextBlock", reason: "required object" },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function assertStats(stats: unknown): asserts stats is {
|
|
|
|
|
wordCount: number;
|
|
|
|
|
characterCount: number;
|
|
|
|
|
blockCount: number;
|
|
|
|
|
todoTotal: number;
|
|
|
|
|
todoDone: number;
|
|
|
|
|
} {
|
|
|
|
|
if (!stats || typeof stats !== "object") {
|
|
|
|
|
throw new DocumentBridgeError("缺少 stats", 400, "VALIDATION_ERROR", [
|
|
|
|
|
{ field: "stats", reason: "required" },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
const record = stats as Record<string, unknown>;
|
|
|
|
|
const fields = ["wordCount", "characterCount", "blockCount", "todoTotal", "todoDone"] as const;
|
|
|
|
|
for (const field of fields) {
|
|
|
|
|
if (typeof record[field] !== "number" || !Number.isFinite(record[field] as number)) {
|
|
|
|
|
throw new DocumentBridgeError("stats 字段非法", 400, "VALIDATION_ERROR", [
|
|
|
|
|
{ field, reason: "must be finite number" },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const PAGE_FONT_VALUES = new Set<PageOptionsState["pageFont"]>(["default", "song", "kai"]);
|
|
|
|
|
const PAGE_LAYOUT_DENSITY_VALUES = new Set<PageOptionsState["layoutDensity"]>(["compact", "normal", "spacious"]);
|
|
|
|
|
const PAGE_OPTION_BOOLEAN_FIELDS = [
|
|
|
|
|
"wideLayout",
|
|
|
|
|
"smallText",
|
|
|
|
|
"showHeadingNumbers",
|
|
|
|
|
"showToc",
|
|
|
|
|
"showStructure",
|
|
|
|
|
"protectEditing",
|
|
|
|
|
"showWordCount",
|
|
|
|
|
"collapseBacklinks",
|
|
|
|
|
"hideChildPages",
|
|
|
|
|
"showBlockRefCount",
|
|
|
|
|
] as const satisfies readonly (keyof PageOptionsState)[];
|
|
|
|
|
const PAGE_OPTION_ALLOWED_FIELDS = new Set<keyof PageOptionsState>([
|
|
|
|
|
...PAGE_OPTION_BOOLEAN_FIELDS,
|
|
|
|
|
"pageFont",
|
|
|
|
|
"layoutDensity",
|
|
|
|
|
"embedDefaultBlockId",
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
export function assertOptionsPatch(
|
|
|
|
|
options: unknown,
|
|
|
|
|
): asserts options is Partial<Pick<PageOptionsState, keyof PageOptionsState>> {
|
|
|
|
|
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
|
|
|
|
throw new DocumentBridgeError("缺少 options", 400, "VALIDATION_ERROR", [
|
|
|
|
|
{ field: "options", reason: "required object" },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const record = options as Record<string, unknown>;
|
|
|
|
|
const entries = Object.entries(record);
|
|
|
|
|
if (entries.length === 0) {
|
|
|
|
|
throw new DocumentBridgeError("缺少 options", 400, "VALIDATION_ERROR", [
|
|
|
|
|
{ field: "options", reason: "must not be empty" },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [field, value] of entries) {
|
|
|
|
|
if (!PAGE_OPTION_ALLOWED_FIELDS.has(field as keyof PageOptionsState)) {
|
|
|
|
|
throw new DocumentBridgeError("options 字段非法", 400, "VALIDATION_ERROR", [
|
|
|
|
|
{ field, reason: "unexpected field" },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ((PAGE_OPTION_BOOLEAN_FIELDS as readonly string[]).includes(field)) {
|
|
|
|
|
if (typeof value !== "boolean") {
|
|
|
|
|
throw new DocumentBridgeError("options 字段非法", 400, "VALIDATION_ERROR", [
|
|
|
|
|
{ field, reason: "must be boolean" },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (field === "pageFont") {
|
|
|
|
|
if (typeof value !== "string" || !PAGE_FONT_VALUES.has(value as PageOptionsState["pageFont"])) {
|
|
|
|
|
throw new DocumentBridgeError("options 字段非法", 400, "VALIDATION_ERROR", [
|
|
|
|
|
{ field, reason: "must be one of default/song/kai" },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (field === "layoutDensity") {
|
|
|
|
|
if (
|
|
|
|
|
typeof value !== "string" ||
|
|
|
|
|
!PAGE_LAYOUT_DENSITY_VALUES.has(value as PageOptionsState["layoutDensity"])
|
|
|
|
|
) {
|
|
|
|
|
throw new DocumentBridgeError("options 字段非法", 400, "VALIDATION_ERROR", [
|
|
|
|
|
{ field, reason: "must be one of compact/normal/spacious" },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (field === "embedDefaultBlockId" && value !== null && typeof value !== "string") {
|
|
|
|
|
throw new DocumentBridgeError("options 字段非法", 400, "VALIDATION_ERROR", [
|
|
|
|
|
{ field, reason: "must be string or null" },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function documentBridgeErrorResponse(error: unknown) {
|
|
|
|
|
if (error instanceof DocumentBridgeError) {
|
|
|
|
|
return apiErrorResponse(error.message, error.status, {
|
|
|
|
|
code: error.code,
|
|
|
|
|
details: error.details,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (error instanceof HttpError) {
|
|
|
|
|
return apiErrorResponse(error.message || "未登录", error.status);
|
|
|
|
|
}
|
|
|
|
|
return apiErrorResponse(error instanceof Error ? error.message : "服务器错误", 500);
|
|
|
|
|
}
|