feat: continue tree rust family cutover
- add rust renderer/state-family scaffolds and inline compat host thinning for page tree, file tree, and picker - route tree/filetree preflight, file projection, resource artifact, and stream delta contracts through rust plans - preserve canonical move-order validation, file-tree search projection, and related frontend/runtime regression coverage
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockIsConvexEnabled = vi.fn(() => true);
|
||||
const mockRequireAuthContext = vi.fn();
|
||||
const mockGetConvexAuthedHttpClient = vi.fn();
|
||||
const mockBuildDocumentBridgeContext = vi.fn();
|
||||
const mockBuildDocumentCommandEnvelope = vi.fn();
|
||||
const mockResolveRustBridgeCommandPlan = vi.fn();
|
||||
const mockExecuteRustBridgeMutationTransport = vi.fn();
|
||||
const mockRecordRustBridgeCommandArtifacts = vi.fn();
|
||||
const mockMaterializeRustTreeStreamDelta = vi.fn();
|
||||
const mockMaterializeRustTreeDomainEventPlan = vi.fn();
|
||||
const mockReadRustTreeDomainEventType = vi.fn();
|
||||
const mockRecordBridgeCommandArtifacts = vi.fn();
|
||||
|
||||
vi.mock("@/lib/convex/enabled", () => ({
|
||||
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/authContext", () => ({
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
requireAuthContext: () => mockRequireAuthContext(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/api", () => ({
|
||||
api: {
|
||||
mediaAssets: {
|
||||
listByIds: "mediaAssets:listByIds",
|
||||
patchById: "mediaAssets:patchById",
|
||||
},
|
||||
documents: {
|
||||
getMeta: "documents:getMeta",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/server", () => ({
|
||||
getConvexAuthedHttpClient: () => mockGetConvexAuthedHttpClient(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge", () => ({
|
||||
buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args),
|
||||
buildDocumentCommandEnvelope: (...args: unknown[]) => mockBuildDocumentCommandEnvelope(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
|
||||
executeRustBridgeMutationTransport: (...args: unknown[]) =>
|
||||
mockExecuteRustBridgeMutationTransport(...args),
|
||||
recordRustBridgeCommandArtifacts: (...args: unknown[]) =>
|
||||
mockRecordRustBridgeCommandArtifacts(...args),
|
||||
materializeRustTreeStreamDelta: (...args: unknown[]) =>
|
||||
mockMaterializeRustTreeStreamDelta(...args),
|
||||
materializeRustTreeDomainEventPlan: (...args: unknown[]) =>
|
||||
mockMaterializeRustTreeDomainEventPlan(...args),
|
||||
readRustTreeDomainEventType: (...args: unknown[]) => mockReadRustTreeDomainEventType(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||
recordBridgeCommandArtifacts: (...args: unknown[]) => mockRecordBridgeCommandArtifacts(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/url/proxyForBrowser", () => ({
|
||||
maybeProxyForBrowserUrl: (_request: Request, url: string) => url,
|
||||
}));
|
||||
|
||||
describe("/api/media/batch route", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockIsConvexEnabled.mockReset().mockReturnValue(true);
|
||||
mockRequireAuthContext.mockReset().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
});
|
||||
mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
workspaceId: "ws_1",
|
||||
actor: { actorType: "user", actorId: "user_1", sessionId: null },
|
||||
source: { channel: "next-route", client: "vitest" },
|
||||
tenantId: null,
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: null,
|
||||
validateOnly: false,
|
||||
dryRun: false,
|
||||
});
|
||||
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => ({
|
||||
...(input as Record<string, unknown>),
|
||||
commandId: "cmd_asset_1",
|
||||
idempotencyKey: null,
|
||||
}));
|
||||
mockResolveRustBridgeCommandPlan.mockReset().mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "tree.resource.move",
|
||||
commandId: "cmd_asset_1",
|
||||
functionName: "mediaAssets:batchMove",
|
||||
argsJson: {
|
||||
domainEventPlan: {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "tree.resource.moved",
|
||||
},
|
||||
domainEventHint: {
|
||||
family: "tree",
|
||||
eventType: "tree.resource.moved",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockExecuteRustBridgeMutationTransport.mockReset().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "asset_1",
|
||||
workspace_id: "ws_1",
|
||||
document_id: "doc_target",
|
||||
asset_type: "file",
|
||||
file_url: "/file.pdf",
|
||||
thumbnail_url: "/file.pdf",
|
||||
file_name: "file.pdf",
|
||||
file_size: 1024,
|
||||
mime_type: "application/pdf",
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
created_at: "2026-04-26T00:00:00Z",
|
||||
updated_at: "2026-04-26T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null);
|
||||
mockMaterializeRustTreeStreamDelta.mockReset().mockReturnValue({
|
||||
op: "upsert_assets",
|
||||
upsertAssets: [{ id: "asset_1" }],
|
||||
});
|
||||
mockMaterializeRustTreeDomainEventPlan.mockReset().mockReturnValue({
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "tree.resource.moved",
|
||||
streamDelta: {
|
||||
op: "upsert_assets",
|
||||
upsertAssets: [{ id: "asset_1" }],
|
||||
},
|
||||
});
|
||||
mockReadRustTreeDomainEventType.mockReset().mockReturnValue("tree.resource.moved");
|
||||
mockRecordBridgeCommandArtifacts.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("copy/move 应进入 Rust resource command plan 并记录资源 delta artifact", async () => {
|
||||
const client = {
|
||||
query: vi.fn(async (name: string, args: Record<string, unknown>) => {
|
||||
if (name === "mediaAssets:listByIds") {
|
||||
expect(args).toEqual({ userId: "user_1", ids: ["asset_1"] });
|
||||
return [{ id: "asset_1", workspace_id: "ws_1" }];
|
||||
}
|
||||
if (name === "documents:getMeta") {
|
||||
expect(args).toEqual({ id: "doc_target" });
|
||||
return { id: "doc_target", workspace_id: "ws_1" };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
mutation: vi.fn(),
|
||||
};
|
||||
mockGetConvexAuthedHttpClient.mockResolvedValue(client);
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "move",
|
||||
assetIds: ["asset_1"],
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
items: [
|
||||
{
|
||||
id: "asset_1",
|
||||
document_id: "doc_target",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "tree.resource.move",
|
||||
payload: {
|
||||
assetIds: ["asset_1"],
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
},
|
||||
target: {
|
||||
workspaceId: "ws_1",
|
||||
pageId: "doc_target",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mockExecuteRustBridgeMutationTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
client,
|
||||
plan: expect.objectContaining({
|
||||
functionName: "mediaAssets:batchMove",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({
|
||||
name: "tree.resource.move",
|
||||
}),
|
||||
plan: expect.objectContaining({
|
||||
commandName: "tree.resource.move",
|
||||
functionName: "mediaAssets:batchMove",
|
||||
}),
|
||||
result: expect.objectContaining({
|
||||
items: [expect.objectContaining({ id: "asset_1" })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { makeUniqueFileName } from "@/lib/file-tree/naming";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordRustBridgeCommandArtifacts,
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -19,41 +25,6 @@ interface BatchPayload {
|
||||
targetSubPath?: string;
|
||||
newName?: string;
|
||||
}
|
||||
|
||||
const BUCKET = process.env.NEXT_PUBLIC_SUPABASE_MEDIA_BUCKET ?? "workspace";
|
||||
const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
|
||||
|
||||
const parseStoragePath = (fileUrl: string) => {
|
||||
try {
|
||||
const url = new URL(fileUrl);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
const objectIdx = segments.findIndex((seg) => seg === "object");
|
||||
if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null;
|
||||
if (segments[objectIdx + 1] === "public") {
|
||||
const bucket = segments[objectIdx + 2];
|
||||
const path = segments.slice(objectIdx + 3).join("/");
|
||||
return { bucket, path };
|
||||
}
|
||||
if (segments[objectIdx + 1] === "sign") {
|
||||
const bucket = segments[objectIdx + 2];
|
||||
const path = segments.slice(objectIdx + 3).join("/");
|
||||
return { bucket, path };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
function resolveAssetLocation(asset: any): { bucket: string; path: string } | null {
|
||||
if (asset?.storage_path) {
|
||||
return { bucket: asset.bucket || BUCKET, path: asset.storage_path };
|
||||
}
|
||||
if (asset?.file_url) {
|
||||
return parseStoragePath(asset.file_url);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeSubPath(input: string | undefined): string {
|
||||
const raw = typeof input === "string" ? input : "";
|
||||
@@ -67,6 +38,14 @@ function sanitizeSubPath(input: string | undefined): string {
|
||||
return cleaned.join("/");
|
||||
}
|
||||
|
||||
function sanitizeTransferredAssetForBrowser(request: Request, asset: any) {
|
||||
return {
|
||||
...asset,
|
||||
file_url: maybeProxyForBrowserUrl(request, String(asset?.file_url ?? "")),
|
||||
thumbnail_url: maybeProxyForBrowserUrl(request, String(asset?.thumbnail_url ?? asset?.file_url ?? "")),
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
@@ -149,65 +128,57 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "目标页面不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const existing = (await client.query(api.mediaAssets.listByDocument, {
|
||||
userId: auth.userId,
|
||||
documentId: payload.targetDocumentId,
|
||||
limit: 500,
|
||||
})) as any[];
|
||||
const existingNames = new Set<string>(
|
||||
(existing ?? []).map((r) => (r?.file_name ?? "").toString()).filter(Boolean),
|
||||
);
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
const fileName = makeUniqueFileName(asset.file_name ?? "附件", existingNames).replace(/[\\/]/g, "_");
|
||||
const storageId = (asset as { storage_id?: string | null })?.storage_id ?? null;
|
||||
if (!storageId) continue;
|
||||
|
||||
if (payload.action === "copy") {
|
||||
const newId =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const created = await client.mutation(api.mediaAssets.createWithStorage, {
|
||||
userId: auth.userId,
|
||||
storageId: storageId as any,
|
||||
asset: {
|
||||
id: newId,
|
||||
workspace_id: String(targetDoc.workspace_id),
|
||||
document_id: String(payload.targetDocumentId),
|
||||
asset_type: String(asset.asset_type ?? "file"),
|
||||
file_name: fileName,
|
||||
file_size: typeof asset.file_size === "number" ? asset.file_size : null,
|
||||
mime_type: (asset.mime_type ?? null) as any,
|
||||
},
|
||||
});
|
||||
|
||||
results.push(created);
|
||||
} else {
|
||||
await client.mutation(api.mediaAssets.patchById, {
|
||||
userId: auth.userId,
|
||||
id: String(asset.id),
|
||||
patch: {
|
||||
workspace_id: String(targetDoc.workspace_id),
|
||||
document_id: String(payload.targetDocumentId),
|
||||
file_name: fileName,
|
||||
},
|
||||
});
|
||||
|
||||
results.push({ ...asset, workspace_id: String(targetDoc.workspace_id), document_id: String(payload.targetDocumentId), file_name: fileName });
|
||||
}
|
||||
const workspaceId = String(targetDoc.workspace_id ?? "").trim();
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
const safeItems = (results ?? []).map((a: any) => ({
|
||||
...a,
|
||||
file_url: maybeProxyForBrowserUrl(request, String(a?.file_url ?? "")),
|
||||
thumbnail_url: maybeProxyForBrowserUrl(request, String(a?.thumbnail_url ?? a?.file_url ?? "")),
|
||||
}));
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId,
|
||||
});
|
||||
const commandName =
|
||||
payload.action === "copy" ? "tree.resource.copy" : "tree.resource.move";
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: commandName,
|
||||
payload: {
|
||||
assetIds: payload.assetIds,
|
||||
targetDocumentId: payload.targetDocumentId,
|
||||
targetSubPath: sanitizeSubPath(payload.targetSubPath),
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
pageId: payload.targetDocumentId,
|
||||
},
|
||||
reason: `media-batch ${commandName}`,
|
||||
refs: ["file-tree-resource-command"],
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
const result = await executeRustBridgeMutationTransport<{ items?: any[] }>({
|
||||
client: client as never,
|
||||
plan,
|
||||
});
|
||||
try {
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client: client as never,
|
||||
plan,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[media.batch] Rust bridge artifacts skipped:", error);
|
||||
}
|
||||
|
||||
return NextResponse.json({ items: safeItems });
|
||||
const safeItems = (result.items ?? []).map((item: any) =>
|
||||
sanitizeTransferredAssetForBrowser(request, item),
|
||||
);
|
||||
|
||||
return NextResponse.json({ items: safeItems });
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockIsConvexEnabled = vi.fn(() => true);
|
||||
const mockRequireAuthContext = vi.fn();
|
||||
const mockGetConvexAuthedHttpClient = vi.fn();
|
||||
const mockBuildDocumentBridgeContext = vi.fn();
|
||||
const mockBuildDocumentCommandEnvelope = vi.fn();
|
||||
const mockResolveRustBridgeCommandPlan = vi.fn();
|
||||
const mockRecordRustBridgeCommandArtifacts = vi.fn();
|
||||
const mockMaterializeRustTreeStreamDelta = vi.fn();
|
||||
const mockMaterializeRustTreeDomainEventPlan = vi.fn();
|
||||
const mockReadRustTreeDomainEventType = vi.fn();
|
||||
const mockRecordBridgeCommandArtifacts = vi.fn();
|
||||
|
||||
vi.mock("@/lib/convex/enabled", () => ({
|
||||
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/authContext", () => ({
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
requireAuthContext: () => mockRequireAuthContext(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/api", () => ({
|
||||
api: {
|
||||
mediaAssets: {
|
||||
generateUploadUrl: "mediaAssets:generateUploadUrl",
|
||||
createWithStorage: "mediaAssets:createWithStorage",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/server", () => ({
|
||||
getConvexAuthedHttpClient: () => mockGetConvexAuthedHttpClient(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge", () => ({
|
||||
buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args),
|
||||
buildDocumentCommandEnvelope: (...args: unknown[]) => mockBuildDocumentCommandEnvelope(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
|
||||
recordRustBridgeCommandArtifacts: (...args: unknown[]) =>
|
||||
mockRecordRustBridgeCommandArtifacts(...args),
|
||||
materializeRustTreeStreamDelta: (...args: unknown[]) =>
|
||||
mockMaterializeRustTreeStreamDelta(...args),
|
||||
materializeRustTreeDomainEventPlan: (...args: unknown[]) =>
|
||||
mockMaterializeRustTreeDomainEventPlan(...args),
|
||||
readRustTreeDomainEventType: (...args: unknown[]) => mockReadRustTreeDomainEventType(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||
recordBridgeCommandArtifacts: (...args: unknown[]) => mockRecordBridgeCommandArtifacts(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/url/proxyForBrowser", () => ({
|
||||
maybeProxyForBrowserUrl: (_request: Request, url: string) => url,
|
||||
}));
|
||||
|
||||
describe("/api/media/upload route", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
mockIsConvexEnabled.mockReset().mockReturnValue(true);
|
||||
mockRequireAuthContext.mockReset().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
});
|
||||
mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
workspaceId: "ws_1",
|
||||
actor: { actorType: "user", actorId: "user_1", sessionId: null },
|
||||
source: { channel: "next-route", client: "vitest" },
|
||||
tenantId: null,
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: null,
|
||||
validateOnly: false,
|
||||
dryRun: false,
|
||||
});
|
||||
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => ({
|
||||
...(input as Record<string, unknown>),
|
||||
commandId: "cmd_upload_asset",
|
||||
idempotencyKey: null,
|
||||
}));
|
||||
mockResolveRustBridgeCommandPlan.mockReset().mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "tree.resource.upload",
|
||||
commandId: "cmd_upload_asset",
|
||||
functionName: "mediaAssets:createWithStorage",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
domainEventPlan: {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "tree.resource.uploaded",
|
||||
},
|
||||
assetId: "asset_upload_1",
|
||||
workspaceId: "ws_1",
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
fileName: "demo.pdf",
|
||||
fileSize: 7,
|
||||
mimeType: "application/pdf",
|
||||
assetType: "file",
|
||||
resourceUploadPlan: {
|
||||
action: "upload",
|
||||
assetId: "asset_upload_1",
|
||||
workspaceId: "ws_1",
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
fileName: "demo.pdf",
|
||||
fileSize: 7,
|
||||
mimeType: "application/pdf",
|
||||
assetType: "file",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockMaterializeRustTreeStreamDelta.mockReset().mockReturnValue({
|
||||
op: "upsert_assets",
|
||||
upsertAssets: [{ id: "asset_upload_1" }],
|
||||
});
|
||||
mockMaterializeRustTreeDomainEventPlan.mockReset().mockReturnValue({
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "tree.resource.uploaded",
|
||||
streamDelta: {
|
||||
op: "upsert_assets",
|
||||
upsertAssets: [{ id: "asset_upload_1" }],
|
||||
},
|
||||
});
|
||||
mockReadRustTreeDomainEventType.mockReset().mockReturnValue("tree.resource.uploaded");
|
||||
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null);
|
||||
mockRecordBridgeCommandArtifacts.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("上传应通过 tree.resource.upload plan 写入资源元数据并记录资源 delta artifact", async () => {
|
||||
const createdAsset = {
|
||||
id: "asset_upload_1",
|
||||
workspace_id: "ws_1",
|
||||
document_id: "doc_target",
|
||||
asset_type: "file",
|
||||
file_url: "/files/demo.pdf",
|
||||
thumbnail_url: "/files/demo.pdf",
|
||||
file_name: "demo.pdf",
|
||||
file_size: 7,
|
||||
mime_type: "application/pdf",
|
||||
created_at: "2026-04-26T00:00:00Z",
|
||||
updated_at: "2026-04-26T00:00:00Z",
|
||||
};
|
||||
const client = {
|
||||
mutation: vi.fn(async (name: string, args: Record<string, unknown>) => {
|
||||
if (name === "mediaAssets:generateUploadUrl") {
|
||||
expect(args).toEqual({ userId: "user_1" });
|
||||
return "https://convex.test/upload";
|
||||
}
|
||||
if (name === "mediaAssets:createWithStorage") {
|
||||
expect(args).toMatchObject({
|
||||
userId: "user_1",
|
||||
storageId: "storage_1",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
resourceUploadPlan: {
|
||||
action: "upload",
|
||||
assetId: "asset_upload_1",
|
||||
targetDocumentId: "doc_target",
|
||||
},
|
||||
asset: {
|
||||
id: "asset_upload_1",
|
||||
workspace_id: "ws_1",
|
||||
document_id: "doc_target",
|
||||
file_name: "demo.pdf",
|
||||
},
|
||||
});
|
||||
return createdAsset;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
mockGetConvexAuthedHttpClient.mockResolvedValue(client);
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ storageId: "storage_1" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const formData = new FormData();
|
||||
formData.append("file", new Blob(["content"], { type: "application/pdf" }), "demo.pdf");
|
||||
formData.set("workspaceId", "ws_1");
|
||||
formData.set("documentId", "doc_target");
|
||||
formData.set("mindmapId", "mind_1");
|
||||
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/media/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
asset: {
|
||||
id: "asset_upload_1",
|
||||
document_id: "doc_target",
|
||||
},
|
||||
mindmapUrl: "asset:asset_upload_1",
|
||||
});
|
||||
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "tree.resource.upload",
|
||||
payload: expect.objectContaining({
|
||||
workspaceId: "ws_1",
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
fileName: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({
|
||||
name: "tree.resource.upload",
|
||||
}),
|
||||
plan: expect.objectContaining({
|
||||
commandName: "tree.resource.upload",
|
||||
functionName: "mediaAssets:createWithStorage",
|
||||
}),
|
||||
result: expect.objectContaining({
|
||||
items: [expect.objectContaining({ id: "asset_upload_1" })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { extname } from "path";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordRustBridgeCommandArtifacts,
|
||||
resolveRustBridgeCommandPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
|
||||
|
||||
const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" => {
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
@@ -20,6 +23,26 @@ const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" =>
|
||||
return "file";
|
||||
};
|
||||
|
||||
function isUploadFile(value: FormDataEntryValue | null): value is File {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
(typeof File === "undefined" || value instanceof File || "arrayBuffer" in value) &&
|
||||
typeof (value as File).arrayBuffer === "function" &&
|
||||
typeof (value as File).name === "string",
|
||||
);
|
||||
}
|
||||
|
||||
function readOptionalStringArg(args: Record<string, unknown>, key: string): string | null {
|
||||
const value = args[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readNumberArg(args: Record<string, unknown>, key: string): number | null {
|
||||
const value = args[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
@@ -38,7 +61,7 @@ export async function POST(request: Request) {
|
||||
const documentId = String(formData.get("documentId") ?? "");
|
||||
const mindmapIdRaw = String(formData.get("mindmapId") ?? "").trim();
|
||||
|
||||
if (!(file instanceof File) || !workspaceId || !documentId) {
|
||||
if (!isUploadFile(file) || !workspaceId || !documentId) {
|
||||
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -51,6 +74,35 @@ export async function POST(request: Request) {
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const assetType = resolveAssetType(file.type || "");
|
||||
const client = await getConvexAuthedHttpClient();
|
||||
const targetSubPath = mindmapIdRaw ? `mindmaps/${mindmapIdRaw}` : undefined;
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "tree.resource.upload",
|
||||
payload: {
|
||||
assetId,
|
||||
workspaceId,
|
||||
targetDocumentId: documentId,
|
||||
targetSubPath,
|
||||
fileName: file.name || null,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type || null,
|
||||
assetType,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
pageId: documentId,
|
||||
},
|
||||
reason: "media-upload tree.resource.upload",
|
||||
refs: ["file-tree-resource-upload"],
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
|
||||
// 1) 获取 Convex 的上传 URL(短时有效)
|
||||
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId: auth.userId });
|
||||
@@ -78,16 +130,32 @@ export async function POST(request: Request) {
|
||||
const created = await client.mutation(api.mediaAssets.createWithStorage, {
|
||||
userId: auth.userId,
|
||||
storageId: storageId as any,
|
||||
targetSubPath: readOptionalStringArg(plan.argsJson, "targetSubPath"),
|
||||
resourceUploadPlan:
|
||||
plan.argsJson.resourceUploadPlan && typeof plan.argsJson.resourceUploadPlan === "object"
|
||||
? plan.argsJson.resourceUploadPlan
|
||||
: undefined,
|
||||
asset: {
|
||||
id: assetId,
|
||||
workspace_id: workspaceId,
|
||||
document_id: documentId,
|
||||
asset_type: assetType,
|
||||
file_name: file.name || null,
|
||||
file_size: file.size,
|
||||
mime_type: file.type || null,
|
||||
id: readOptionalStringArg(plan.argsJson, "assetId") ?? assetId,
|
||||
workspace_id: readOptionalStringArg(plan.argsJson, "workspaceId") ?? workspaceId,
|
||||
document_id: readOptionalStringArg(plan.argsJson, "targetDocumentId") ?? documentId,
|
||||
asset_type: readOptionalStringArg(plan.argsJson, "assetType") ?? assetType,
|
||||
file_name: readOptionalStringArg(plan.argsJson, "fileName"),
|
||||
file_size: readNumberArg(plan.argsJson, "fileSize"),
|
||||
mime_type: readOptionalStringArg(plan.argsJson, "mimeType"),
|
||||
},
|
||||
});
|
||||
try {
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client: client as never,
|
||||
plan,
|
||||
result: { items: [created] },
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[media.upload] Rust bridge artifacts skipped:", error);
|
||||
}
|
||||
|
||||
const asset = created as unknown as MediaAsset;
|
||||
const safeAsset = {
|
||||
|
||||
Reference in New Issue
Block a user