{
          id: "ws_1",
          name: "工作区",
          type: "personal",
          iconUrl: null,
          memberCount: 1,
          isDefault: true,
        },
      ],
      documents: [
        {
          id: "doc_1",
          workspace_id: "ws_1",
          title: "页面 1",
          parent_id: null,
          sort_order: 1,
          is_starred: false,
          access_scope: "private",
          is_template: false,
          created_at: "2026-04-14T00:00:00Z",
          updated_at: null,
        },
      ],
      kernel_sidebar_projection: {
        projectionId: "kernel_projection:sidebar_tree:workspace_root",
        projection: "sidebar_tree",
        rootNodeId: null,
        items: [
          {
            nodeId: "doc_1",
            parentNodeId: null,
            nodeType: "page",
            projectionKind: "sidebar_tree",
            title: "页面 1",
            depth: 0,
            position: 1,
            childCount: 0,
            expandable: false,
            expandedByDefault: true,
            capabilities: [
              "open",
              "drag",
              "drop",
              "select",
              "create-child",
              "rename",
              "archive",
              "restore",
              "context-menu",
              "reorder",
            ],
            resourceMeta: {
              resourceKind: "document",
              documentId: "doc_1",
              workspaceId: "ws_1",
              iconHint: "page",
            },
            iconHint: "page",
          },
        ],


"context-menu",
              "reorder",
            ],
            resourceMeta: {
              resourceKind: "document",
              documentId: "doc_1",
              workspaceId: "ws_1",
              iconHint: "page",
            },
            iconHint: "page",
          },
        ],
        edges: [],
      },
      kernel_file_tree_projection: {
        projectionId: "kernel_projection:file_tree:root",
        projection: "file_tree",
        rootNodeId: null,
        meta: {
          search: {
            ordering: "kernel_file_tree_preorder",
          },
        },
        items: [
          {
            rowId: "doc:doc_1",
            rowKind: "document",
            nodeId: "doc_1",
            parentNodeId: null,
            nodeType: "page",
            projectionKind: "file_tree",
            title: "页面 1",
            depth: 0,
            position: 1,
            childCount: 1,
            expandable: true,
            expandedByDefault: true,
            capabilities: [
              "expand",
              "open",
              "drag",
              "drop",
              "select",
              "create-child",
              "rename",
              "archive",
              "restore",
              "context-menu",
              "reorder",
            ],
            resourceMeta: {
              resourceKind: "document",
              documentId: "doc_1",
              workspaceId: "ws_1",
              iconHint: "page",
            },
            iconHint: "page",
          },
          {
            rowId: "index:doc_1",


"reorder",
            ],
            resourceMeta: {
              resourceKind: "document",
              documentId: "doc_1",
              workspaceId: "ws_1",
              iconHint: "page",
            },
            iconHint: "page",
          },
          {
            rowId: "index:doc_1",
            rowKind: "index",
            nodeId: "index:doc_1",
            parentNodeId: "doc_1",
            nodeType: "index",
            projectionKind: "file_tree",
            title: "index.md",
            depth: 1,
            position: 0,
            childCount: 0,
            expandable: false,
            expandedByDefault: false,
            capabilities: ["open", "select", "context-menu"],
            resourceMeta: {
              resourceKind: "index",
              documentId: "doc_1",
              workspaceId: "ws_1",
              iconHint: "index",
            },
            iconHint: "index",
          },
        ],
        edges: [
          {
            id: "edge:doc_1:index:doc_1:parent_of",
            edgeType: "parent_of",
            workspaceId: "ws_1",
            fromNodeId: "doc_1",
            toNodeId: "index:doc_1",
          },
        ],
      },
      trashed_documents: [],
      media_assets: [],
      trashed_media_assets: [],
      mindmap_assets: [],
      trashed_mindmap_assets: [],
      table_assets: [],
      trashed_table_assets: [],
      mindmap_docs: [],
      mindmap_asset_children: {},
    });

    expect(mapSidebarDatasetListQueryResultToInitialData(queryResult)).toMatchObject({
      activeWorkspaceId: "ws_1",
      workspaces: [
        {
          id: "ws_1",
          name: "工作区",


table_assets: [],
      trashed_table_assets: [],
      mindmap_docs: [],
      mindmap_asset_children: {},
    });

    expect(mapSidebarDatasetListQueryResultToInitialData(queryResult)).toMatchObject({
      activeWorkspaceId: "ws_1",
      workspaces: [
        {
          id: "ws_1",
          name: "工作区",
          type: "personal",
          iconUrl: null,
          memberCount: 1,
          isDefault: true,
        },
      ],
      documents: [
        {
          id: "doc_1",
          workspace_id: "ws_1",
          title: "页面 1",
          parent_id: null,
          sort_order: 1,
          is_starred: false,
          access_scope: "private",
          is_template: false,
          created_at: "2026-04-14T00:00:00Z",
          updated_at: null,
        },
      ],
      kernelSidebarProjection: {
        projectionId: "kernel_projection:sidebar_tree:workspace_root",
        projection: "sidebar_tree",
        rootNodeId: null,
        items: [
          {
            nodeId: "doc_1",
            parentNodeId: null,
            nodeType: "page",
            projectionKind: "sidebar_tree",
            title: "页面 1",
            depth: 0,
            position: 1,
            childCount: 0,
            expandable: false,
            expandedByDefault: true,
            capabilities: [
              "open",
              "drag",
              "drop",
              "select",
              "create-child",
              "rename",
              "archive",
              "restore",
              "context-menu",
              "reorder",
            ],


capabilities: [
              "open",
              "drag",
              "drop",
              "select",
              "create-child",
              "rename",
              "archive",
              "restore",
              "context-menu",
              "reorder",
            ],
            resourceMeta: {
              resourceKind: "document",
              documentId: "doc_1",
              workspaceId: "ws_1",
              iconHint: "page",
            },
            iconHint: "page",
          },
        ],
        edges: [],
      },
      kernelSidebarTree: [
        {
          access_scope: "private",
          id: "doc_1",
          workspace_id: "ws_1",
          title: "页面 1",
          parent_id: null,
          sort_order: 1,
          is_starred: false,
          is_template: false,
          created_at: "2026-04-14T00:00:00Z",
          updated_at: null,
          children: [],
          kernel: {
            nodeType: "page",
            depth: 0,
            position: 1,
            childCount: 0,
            expandedByDefault: true,
          },
        },
      ],
      kernelFileTreeProjection: {
        projectionId: "kernel_projection:file_tree:root",
        projection: "file_tree",
        rootNodeId: null,
        meta: {
          search: {
            ordering: "kernel_file_tree_preorder",
          },
        },
        items: [
          {
            rowId: "doc:doc_1",
            rowKind: "document",
            nodeId: "doc_1",
            parentNodeId: null,


rootNodeId: null,
        meta: {
          search: {
            ordering: "kernel_file_tree_preorder",
          },
        },
        items: [
          {
            rowId: "doc:doc_1",
            rowKind: "document",
            nodeId: "doc_1",
            parentNodeId: null,
            nodeType: "page",
            projectionKind: "file_tree",
            title: "页面 1",
            depth: 0,
            position: 1,
            childCount: 1,
            expandable: true,
            expandedByDefault: true,
            capabilities: [
              "expand",
              "open",
              "drag",
              "drop",
              "select",
              "create-child",
              "rename",
              "archive",
              "restore",
              "context-menu",
              "reorder",
            ],
            resourceMeta: {
              resourceKind: "document",
              documentId: "doc_1",
              workspaceId: "ws_1",
              iconHint: "page",
            },
            iconHint: "page",
          },
          {
            rowId: "index:doc_1",
            rowKind: "index",
            nodeId: "index:doc_1",
            parentNodeId: "doc_1",
            nodeType: "index",
            projectionKind: "file_tree",
            title: "index.md",
            depth: 1,
            position: 0,
            childCount: 0,
            expandable: false,
            expandedByDefault: false,
            capabilities: ["open", "select", "context-menu"],
            resourceMeta: {
              resourceKind: "index",
              documentId: "doc_1",
              workspaceId: "ws_1",
              iconHint: "index",


title: "index.md",
            depth: 1,
            position: 0,
            childCount: 0,
            expandable: false,
            expandedByDefault: false,
            capabilities: ["open", "select", "context-menu"],
            resourceMeta: {
              resourceKind: "index",
              documentId: "doc_1",
              workspaceId: "ws_1",
              iconHint: "index",
            },
            iconHint: "index",
          },
        ],
        edges: [
          {
            id: "edge:doc_1:index:doc_1:parent_of",
            edgeType: "parent_of",
            workspaceId: "ws_1",
            fromNodeId: "doc_1",
            toNodeId: "index:doc_1",
          },
        ],
      },
      trashedDocuments: [],
      trashedMediaAssets: [],
      trashedMindmapAssets: [],
      trashedTableAssets: [],
      mindmapDocs: [],
      mindmapAssets: [],
      mindmapAssetChildren: {},
      tableAssets: [],
      mediaAssets: [],
    });
  });

  it("缺少 kernel projection 时不再本地重建第二份 projection", () => {
    expect(
      mapSidebarDatasetListQueryResultToInitialData({
        active_workspace_id: "ws_1",
        workspaces: [],
        documents: [
          {
            id: "doc_1",
            workspace_id: "ws_1",
            title: "页面 1",
            parent_id: null,
            sort_order: 1,
            is_starred: false,
            access_scope: "private",
            is_template: false,
            created_at: "2026-04-14T00:00:00Z",
            updated_at: null,
          },
          {
            id: "doc_2",
            workspace_id: "ws_1",
            title: "页面 2",


parent_id: null,
            sort_order: 1,
            is_starred: false,
            access_scope: "private",
            is_template: false,
            created_at: "2026-04-14T00:00:00Z",
            updated_at: null,
          },
          {
            id: "doc_2",
            workspace_id: "ws_1",
            title: "页面 2",
            parent_id: "doc_1",
            sort_order: 0,
            is_starred: false,
            access_scope: "private",
            is_template: false,
            created_at: "2026-04-14T00:01:00Z",
            updated_at: null,
          },
        ],
        trashed_documents: [],
        media_assets: [],
        trashed_media_assets: [],
        mindmap_assets: [],
        trashed_mindmap_assets: [],
        table_assets: [],
        trashed_table_assets: [],
        mindmap_docs: [],
        mindmap_asset_children: {},
      }),
    ).toMatchObject({
      activeWorkspaceId: "ws_1",
      kernelSidebarProjection: {
        projectionId: "kernel_projection:sidebar_tree:missing",
      },
      kernelFileTreeProjection: {
        projectionId: "kernel_projection:file_tree:missing",
      },
      kernelSidebarTree: [],
    });
  });
});


import type { SidebarInitialData } from "@/components/sidebar/types";
import type { DocumentRecord } from "@/lib/documents";
import {
  buildKernelFileTreeProjection,
  isKernelFileTreeProjection,
  type KernelFileTreeProjection,
} from "@/lib/kernel-file-tree";
import {
  buildKernelSidebarProjection as buildProjectionContract,
  buildSidebarTreeFromKernelProjection,
  type KernelSidebarProjection,
} from "@/lib/kernel-sidebar";
import type { WorkspaceSummary } from "@/lib/workspaces";
import type { MediaAsset } from "@/types/media";

type MindmapRow = {
  mindmap_id: string;
  workspace_id?: string | null;
  document_id: string;
  data?: unknown;
  created_at?: string | null;
  updated_at?: string | null;
  deleted_at?: string | null;
  deleted_by?: string | null;
};

type TableRow = {
  id: string;
  workspace_id?: string | null;
  document_id: string;
  title?: string | null;
  created_at?: string | null;
  updated_at?: string | null;
  deleted_at?: string | null;
  deleted_by?: string | null;
  purged_at?: string | null;
  is_archived?: boolean | null;
};

export type SidebarDatasetInput = {
  activeWorkspaceId: string;
  workspaces: WorkspaceSummary[];
  documents: DocumentRecord[];
  trashedDocuments: SidebarInitialData["trashedDocuments"];
  mindmaps: MindmapRow[];
  mediaAssets?: MediaAsset[] | null;
  trashedMediaAssets?: MediaAsset[] | null;
  tables?: TableRow[] | null;
};

export type SidebarDatasetListQueryPayload = {
  workspace_id: string;
};

export type SidebarDatasetListQueryResult = {
  active_workspace_id: string;
  workspaces: WorkspaceSummary[];
  documents: DocumentRecord[];
  kernel_file_tree_projection?: KernelFileTreeProjection;
  kernelFileTreeProjection?: KernelFileTreeProjection;


};

export type SidebarDatasetListQueryPayload = {
  workspace_id: string;
};

export type SidebarDatasetListQueryResult = {
  active_workspace_id: string;
  workspaces: WorkspaceSummary[];
  documents: DocumentRecord[];
  kernel_file_tree_projection?: KernelFileTreeProjection;
  kernelFileTreeProjection?: KernelFileTreeProjection;
  kernel_sidebar_projection?: KernelSidebarProjection;
  kernelSidebarProjection?: KernelSidebarProjection;
  trashed_documents: SidebarInitialData["trashedDocuments"];
  media_assets: MediaAsset[];
  trashed_media_assets: MediaAsset[];
  mindmap_assets: MediaAsset[];
  trashed_mindmap_assets: MediaAsset[];
  table_assets: MediaAsset[];
  trashed_table_assets: MediaAsset[];
  mindmap_docs: string[];
  mindmap_asset_children: Record<string, string[]>;
};

const EMPTY_KERNEL_SIDEBAR_PROJECTION: KernelSidebarProjection = {
  projectionId: "kernel_projection:sidebar_tree:missing",
  projection: "sidebar_tree",
  rootNodeId: null,
  items: [],
  edges: [],
};

const EMPTY_KERNEL_FILE_TREE_PROJECTION: KernelFileTreeProjection = {
  projectionId: "kernel_projection:file_tree:missing",
  projection: "file_tree",
  rootNodeId: null,
  items: [],
  edges: [],
};

function isKernelSidebarProjection(value: unknown): value is KernelSidebarProjection {
  return (
    Boolean(value) &&
    typeof value === "object" &&
    Array.isArray((value as KernelSidebarProjection).items) &&
    Array.isArray((value as KernelSidebarProjection).edges)
  );
}

function readKernelSidebarProjection(
  result: SidebarDatasetListQueryResult,
): KernelSidebarProjection {
  const snakeCaseProjection =
    "kernel_sidebar_projection" in result ? result.kernel_sidebar_projection : undefined;
  if (isKernelSidebarProjection(snakeCaseProjection)) {
    return snakeCaseProjection;
  }

  const camelCaseProjection =


}

function readKernelSidebarProjection(
  result: SidebarDatasetListQueryResult,
): KernelSidebarProjection {
  const snakeCaseProjection =
    "kernel_sidebar_projection" in result ? result.kernel_sidebar_projection : undefined;
  if (isKernelSidebarProjection(snakeCaseProjection)) {
    return snakeCaseProjection;
  }

  const camelCaseProjection =
    "kernelSidebarProjection" in result ? result.kernelSidebarProjection : undefined;
  if (isKernelSidebarProjection(camelCaseProjection)) {
    return camelCaseProjection;
  }

  return EMPTY_KERNEL_SIDEBAR_PROJECTION;
}

function readKernelFileTreeProjection(
  result: SidebarDatasetListQueryResult,
): KernelFileTreeProjection {
  const snakeCaseProjection =
    "kernel_file_tree_projection" in result ? result.kernel_file_tree_projection : undefined;
  if (isKernelFileTreeProjection(snakeCaseProjection)) {
    return snakeCaseProjection;
  }

  const camelCaseProjection =
    "kernelFileTreeProjection" in result ? result.kernelFileTreeProjection : undefined;
  if (isKernelFileTreeProjection(camelCaseProjection)) {
    return camelCaseProjection;
  }

  return EMPTY_KERNEL_FILE_TREE_PROJECTION;
}

function normalizeStringArray(values: Iterable<string>): string[] {
  return Array.from(new Set(values)).filter((value) => value.trim().length > 0);
}

export function buildSidebarDatasetListQueryPayload(
  workspaceId: string,
): SidebarDatasetListQueryPayload {
  return {
    workspace_id: workspaceId.trim(),
  };
}

export function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
  const root = (() => {
    if (!input || typeof input !== "object") return input;
    const record = input as Record<string, unknown>;
    return record && typeof record === "object" && "root" in record ? record.root : input;
  })();

  const ids: string[] = [];
  const seen = new Set<string>();


}

export function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
  const root = (() => {
    if (!input || typeof input !== "object") return input;
    const record = input as Record<string, unknown>;
    return record && typeof record === "object" && "root" in record ? record.root : input;
  })();

  const ids: string[] = [];
  const seen = new Set<string>();

  const push = (value: unknown) => {
    if (typeof value !== "string") return;
    if (!value.startsWith("asset:")) return;
    const id = value.slice("asset:".length).trim();
    if (!id || seen.has(id)) return;
    seen.add(id);
    ids.push(id);
  };

  const get = (obj: unknown, key: string): unknown => {
    if (!obj || typeof obj !== "object") return undefined;
    return (obj as Record<string, unknown>)[key];
  };

  const walk = (node: unknown) => {
    if (!node || typeof node !== "object") return;

    const data = get(node, "data");
    const image = get(node, "image");

    push(get(data, "image"));
    push(image);
    push(get(image, "url"));
    push(get(get(data, "image"), "url"));

    const children = get(node, "children");
    if (Array.isArray(children)) {
      children.forEach(walk);
    }
  };

  walk(root);
  return ids;
}

function toMindmapAsset(row: MindmapRow, workspaceId: string): MediaAsset {
  const isLegacy = row.mindmap_id.startsWith("legacy-");
  const suffix = row.mindmap_id.match(/(\d{4,})$/)?.[1]?.slice(-4) ?? row.mindmap_id.replace(/^mindmap[_-]?/i, "").slice(-6);
  const fileName = suffix ? `思维导图-${suffix}.json` : "思维导图.json";
  return {
    id: row.mindmap_id,
    workspace_id: row.workspace_id ?? workspaceId,
    document_id: row.document_id,
    asset_type: "mindmap",
    file_url: null,
    thumbnail_url: null,
    bucket: null,
    storage_path: null,


const isLegacy = row.mindmap_id.startsWith("legacy-");
  const suffix = row.mindmap_id.match(/(\d{4,})$/)?.[1]?.slice(-4) ?? row.mindmap_id.replace(/^mindmap[_-]?/i, "").slice(-6);
  const fileName = suffix ? `思维导图-${suffix}.json` : "思维导图.json";
  return {
    id: row.mindmap_id,
    workspace_id: row.workspace_id ?? workspaceId,
    document_id: row.document_id,
    asset_type: "mindmap",
    file_url: null,
    thumbnail_url: null,
    bucket: null,
    storage_path: null,
    file_name: isLegacy ? "思维导图.json" : fileName,
    file_size: null,
    mime_type: "application/json",
    ocr_payload: undefined,
    ocr_strategy: null,
    ocr_text: null,
    ocr_status: null,
    signed_url: null,
    created_at: row.created_at ?? "",
    updated_at: row.updated_at ?? "",
  };
}

function toTrashedMindmapAsset(row: MindmapRow, workspaceId: string): MediaAsset {
  return {
    ...toMindmapAsset(row, workspaceId),
    deleted_at: row.deleted_at ?? null,
    deleted_by: row.deleted_by ?? null,
    purged_at: null,
  };
}

function toTableAsset(row: TableRow, workspaceId: string): MediaAsset {
  const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
  const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
  return {
    id: row.id,
    workspace_id: row.workspace_id ?? workspaceId,
    document_id: row.document_id,
    asset_type: "luckysheet",
    file_url: null,
    thumbnail_url: null,
    bucket: null,
    storage_path: null,
    file_name: fileName,
    file_size: null,
    mime_type: "application/json",
    ocr_payload: undefined,
    ocr_strategy: null,
    ocr_text: null,
    ocr_status: null,
    signed_url: null,
    created_at: row.created_at ?? "",
    updated_at: row.updated_at ?? "",
  };
}

function toTrashedTableAsset(row: TableRow, workspaceId: string): MediaAsset {


mime_type: "application/json",
    ocr_payload: undefined,
    ocr_strategy: null,
    ocr_text: null,
    ocr_status: null,
    signed_url: null,
    created_at: row.created_at ?? "",
    updated_at: row.updated_at ?? "",
  };
}

function toTrashedTableAsset(row: TableRow, workspaceId: string): MediaAsset {
  return {
    ...toTableAsset(row, workspaceId),
    deleted_at: row.deleted_at ?? row.updated_at ?? null,
    deleted_by: row.deleted_by ?? null,
    purged_at: row.purged_at ?? null,
  };
}

function deriveSidebarDataset(input: SidebarDatasetInput) {
  const activeMindmaps = input.mindmaps.filter((row) => !row.deleted_at);
  const trashedMindmaps = input.mindmaps.filter((row) => Boolean(row.deleted_at));
  const activeTables = (input.tables ?? []).filter((row) => !row.is_archived);
  const trashedTables = (input.tables ?? []).filter((row) => Boolean(row.is_archived));

  const mindmapAssetChildren: Record<string, string[]> = {};
  activeMindmaps.forEach((row) => {
    const ids = extractMindmapImageAssetIdsFromData(row.data);
    if (ids.length > 0) {
      mindmapAssetChildren[row.mindmap_id] = ids;
    }
  });

  return {
    mindmapAssetChildren,
    mindmapAssets: activeMindmaps.map((row) => toMindmapAsset(row, input.activeWorkspaceId)),
    mindmapDocs: normalizeStringArray(activeMindmaps.map((row) => row.document_id)),
    tableAssets: activeTables.map((row) => toTableAsset(row, input.activeWorkspaceId)),
    trashedMindmapAssets: trashedMindmaps.map((row) =>
      toTrashedMindmapAsset(row, input.activeWorkspaceId),
    ),
    trashedTableAssets: trashedTables.map((row) =>
      toTrashedTableAsset(row, input.activeWorkspaceId),
    ),
  };
}

export function buildSidebarDatasetListQueryResult(
  input: SidebarDatasetInput,
): SidebarDatasetListQueryResult {
  const derived = deriveSidebarDataset(input);
  const kernelSidebarProjection = buildProjectionContract(input.documents);
  const kernelFileTreeProjection = buildKernelFileTreeProjection({
    documents: input.documents,
    mediaAssets: input.mediaAssets,
    mindmapAssets: derived.mindmapAssets,
    tableAssets: derived.tableAssets,
    mindmapAssetChildren: derived.mindmapAssetChildren,
  });


export function buildSidebarDatasetListQueryResult(
  input: SidebarDatasetInput,
): SidebarDatasetListQueryResult {
  const derived = deriveSidebarDataset(input);
  const kernelSidebarProjection = buildProjectionContract(input.documents);
  const kernelFileTreeProjection = buildKernelFileTreeProjection({
    documents: input.documents,
    mediaAssets: input.mediaAssets,
    mindmapAssets: derived.mindmapAssets,
    tableAssets: derived.tableAssets,
    mindmapAssetChildren: derived.mindmapAssetChildren,
  });

  return {
    active_workspace_id: input.activeWorkspaceId,
    workspaces: [...input.workspaces],
    documents: [...input.documents],
    kernel_file_tree_projection: kernelFileTreeProjection,
    kernel_sidebar_projection: kernelSidebarProjection,
    trashed_documents: [...input.trashedDocuments],
    media_assets: [...(input.mediaAssets ?? [])],
    trashed_media_assets: [...(input.trashedMediaAssets ?? [])],
    mindmap_assets: derived.mindmapAssets,
    trashed_mindmap_assets: derived.trashedMindmapAssets,
    table_assets: derived.tableAssets,
    trashed_table_assets: derived.trashedTableAssets,
    mindmap_docs: derived.mindmapDocs,
    mindmap_asset_children: { ...derived.mindmapAssetChildren },
  };
}

export function mapSidebarDatasetListQueryResultToInitialData(
  result: SidebarDatasetListQueryResult,
): SidebarInitialData {
  const kernelSidebarProjection = readKernelSidebarProjection(result);
  const kernelFileTreeProjection = readKernelFileTreeProjection(result);

  return {
    activeWorkspaceId: result.active_workspace_id,
    workspaces: [...result.workspaces],
    documents: [...result.documents],
    kernelSidebarProjection,
    kernelSidebarTree: buildSidebarTreeFromKernelProjection({
      records: result.documents,
      projection: kernelSidebarProjection,
    }),
    kernelFileTreeProjection,
    trashedDocuments: [...result.trashed_documents],
    trashedMediaAssets: [...result.trashed_media_assets],
    trashedMindmapAssets: [...result.trashed_mindmap_assets],
    trashedTableAssets: [...result.trashed_table_assets],
    mindmapDocs: [...result.mindmap_docs],
    mindmapAssets: [...result.mindmap_assets],
    mindmapAssetChildren: { ...result.mindmap_asset_children },
    tableAssets: [...result.table_assets],
    mediaAssets: [...result.media_assets],
  };
}

export function buildSidebarInitialData(input: SidebarDatasetInput): SidebarInitialData {


trashedMediaAssets: [...result.trashed_media_assets],
    trashedMindmapAssets: [...result.trashed_mindmap_assets],
    trashedTableAssets: [...result.trashed_table_assets],
    mindmapDocs: [...result.mindmap_docs],
    mindmapAssets: [...result.mindmap_assets],
    mindmapAssetChildren: { ...result.mindmap_asset_children },
    tableAssets: [...result.table_assets],
    mediaAssets: [...result.media_assets],
  };
}

export function buildSidebarInitialData(input: SidebarDatasetInput): SidebarInitialData {
  const queryResult = buildSidebarDatasetListQueryResult(input);

  return mapSidebarDatasetListQueryResultToInitialData(queryResult);
}


import type { DocumentRecord } from "@/lib/documents";
import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types";
import type { Database } from "@/types/supabase";
import type { MediaAsset } from "@/types/media";
import {
  buildKernelSidebarProjection,
  buildSidebarTreeFromKernelProjection,
  type SidebarTreeNode,
} from "@/lib/kernel-sidebar";

type TypedClient = {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  from: (table: keyof Database["public"]["Tables"] | string) => any;
};

export type SidebarTableRow = {
  id: string;
  workspace_id: string | null;
  document_id: string;
  title: string | null;
  created_at: string | null;
  updated_at: string | null;
};

export interface SidebarDataset {
  documents: DocumentRecord[];
  trashedDocuments: TrashRecord[];
  trashedMediaAssets: MediaAsset[];
  /**
   * 仅依据远端 mindmap_data 判定的页面 id 列表；本地文件检测由服务器侧辅助完成
   */
  mindmapDocs: string[];
  mediaAssets?: MediaAsset[];
  tables?: SidebarTableRow[];
}

export async function fetchSidebarDataset(
  client: TypedClient,
  workspaceId: string,
): Promise<SidebarDataset> {
  const { data: documentRows, error } = await client
    .from("documents")
    .select(
      "id,title,parent_id,sort_order,is_starred,access_scope,is_template,created_at,updated_at,workspace_id,mindmap_data",
    )
    .eq("workspace_id", workspaceId)
    .is("deleted_at", null)
    .order("sort_order", { ascending: true, nullsFirst: false })
    .order("created_at", { ascending: true });

  if (error) {
    throw new Error(`获取工作空间文档列表失败：${error.message}`);
  }

  const documentRowsAny = (documentRows ?? []) as any[];
  const documents: DocumentRecord[] = documentRowsAny.map((row) => ({
    access_scope: (row.access_scope ?? "private") as DocumentRecord["access_scope"],
    id: row.id,
    workspace_id: row.workspace_id,
    title: row.title ?? "无标题",


.order("created_at", { ascending: true });

  if (error) {
    throw new Error(`获取工作空间文档列表失败：${error.message}`);
  }

  const documentRowsAny = (documentRows ?? []) as any[];
  const documents: DocumentRecord[] = documentRowsAny.map((row) => ({
    access_scope: (row.access_scope ?? "private") as DocumentRecord["access_scope"],
    id: row.id,
    workspace_id: row.workspace_id,
    title: row.title ?? "无标题",
    parent_id: row.parent_id,
    sort_order: row.sort_order,
    is_starred: row.is_starred,
    is_template: row.is_template ?? false,
    created_at: row.created_at ?? "",
    updated_at: row.updated_at ?? null,
  }));

  const { data: trashRows, error: trashError } = await client
    .from("documents")
    .select("id,title,parent_id,deleted_at,access_scope")
    .eq("workspace_id", workspaceId)
    .not("deleted_at", "is", null)
    .order("deleted_at", { ascending: false })
    .limit(100);

  if (trashError) {
    throw new Error(`获取垃圾桶内容失败：${trashError.message}`);
  }

  const trashRowsAny = (trashRows ?? []) as any[];
  const trashedDocuments: TrashRecord[] = trashRowsAny.map((row) => ({
    id: row.id,
    title: row.title,
    parent_id: row.parent_id,
    deleted_at: row.deleted_at!,
    access_scope: (row.access_scope ?? "private") as DocumentRecord["access_scope"],
  }));

  const mindmapDocs = documentRowsAny.filter((row) => row.mindmap_data != null).map((row) => row.id);

  const { data: assetRows, error: assetError } = await client
    .from("media_assets")
    .select("*")
    .eq("workspace_id", workspaceId)
    .is("deleted_at", null)
    .order("created_at", { ascending: false });

  if (assetError) {
    throw new Error(`获取附件列表失败：${assetError.message}`);
  }

  const mediaAssets: MediaAsset[] = (assetRows ?? []) as MediaAsset[];

  const { data: trashedAssetRows, error: trashedAssetError } = await client
    .from("media_assets")
    .select("*")
    .eq("workspace_id", workspaceId)


.order("created_at", { ascending: false });

  if (assetError) {
    throw new Error(`获取附件列表失败：${assetError.message}`);
  }

  const mediaAssets: MediaAsset[] = (assetRows ?? []) as MediaAsset[];

  const { data: trashedAssetRows, error: trashedAssetError } = await client
    .from("media_assets")
    .select("*")
    .eq("workspace_id", workspaceId)
    .not("deleted_at", "is", null)
    .is("purged_at", null)
    .order("deleted_at", { ascending: false })
    .limit(200);

  if (trashedAssetError) {
    throw new Error(`获取附件垃圾桶失败：${trashedAssetError.message}`);
  }

  const trashedMediaAssets: MediaAsset[] = (trashedAssetRows ?? []) as MediaAsset[];

  // 说明：当前 supabase types 可能未包含 document_tables；这里用 any 兜底，
  // 避免类型缺失阻塞侧边栏功能。
  const { data: tableRows, error: tableError } = await (client as any)
    .from("document_tables")
    .select("id,workspace_id,document_id,title,created_at,updated_at,is_archived")
    .eq("workspace_id", workspaceId)
    .eq("is_archived", false)
    .order("created_at", { ascending: true });

  if (tableError) {
    throw new Error(`获取在线表格列表失败：${tableError.message}`);
  }

  const tables: SidebarTableRow[] = (tableRows ?? []) as unknown as SidebarTableRow[];

  return {
    documents,
    trashedDocuments,
    trashedMediaAssets,
    mindmapDocs,
    mediaAssets,
    tables,
  };
}

type NodePredicate = (node: SidebarTreeNode) => boolean;

function projectTree(nodes: SidebarTreeNode[], predicate: NodePredicate): SidebarTreeNode[] {
  const result: SidebarTreeNode[] = [];
  nodes.forEach((node) => {
    const projectedChildren = projectTree(node.children, predicate);
    if (predicate(node)) {
      result.push({
        ...node,
        children: projectedChildren,
      });
    } else {


type NodePredicate = (node: SidebarTreeNode) => boolean;

function projectTree(nodes: SidebarTreeNode[], predicate: NodePredicate): SidebarTreeNode[] {
  const result: SidebarTreeNode[] = [];
  nodes.forEach((node) => {
    const projectedChildren = projectTree(node.children, predicate);
    if (predicate(node)) {
      result.push({
        ...node,
        children: projectedChildren,
      });
    } else {
      result.push(...projectedChildren);
    }
  });
  return result;
}

export function flattenDocumentTree(
  nodes: SidebarTreeNode[],
  expanded: Set<string>,
  depth = 0,
  parentId: string | null = null,
): Array<{ node: SidebarTreeNode; depth: number; parentId: string | null }> {
  const list: Array<{ node: SidebarTreeNode; depth: number; parentId: string | null }> = [];
  nodes.forEach((node) => {
    list.push({ node, depth, parentId });
    if (node.children.length > 0 && expanded.has(node.id)) {
      list.push(...flattenDocumentTree(node.children, expanded, depth + 1, node.id));
    }
  });
  return list;
}

const SECTION_META: Record<SidebarSectionId, { title: string; icon: string }> = {
  starred: { title: "星标置顶", icon: "star" },
  public: { title: "公共页面", icon: "globe" },
  shared: { title: "共享页面", icon: "users" },
  private: { title: "私有 / 我的页面", icon: "lock" },
  templates: { title: "模板中心", icon: "grid" },
};

type SidebarSectionSnapshot = {
  id: SidebarSectionId;
  title: string;
  icon?: string;
  nodes: SidebarTreeNode[];
};

export function buildSidebarSections(records: DocumentRecord[]): SidebarSectionSnapshot[] {
  const tree = buildSidebarTreeFromKernelProjection({
    records,
    projection: buildKernelSidebarProjection(records),
  });
  return buildSidebarSectionsFromTree(tree);
}

export function buildSidebarSectionsFromTree(
  tree: SidebarTreeNode[],
): SidebarSectionSnapshot[] {


export function buildSidebarSections(records: DocumentRecord[]): SidebarSectionSnapshot[] {
  const tree = buildSidebarTreeFromKernelProjection({
    records,
    projection: buildKernelSidebarProjection(records),
  });
  return buildSidebarSectionsFromTree(tree);
}

export function buildSidebarSectionsFromTree(
  tree: SidebarTreeNode[],
): SidebarSectionSnapshot[] {
  const sections: Array<{ id: SidebarSectionId; predicate: NodePredicate }> = [
    { id: "starred", predicate: (node) => Boolean(node.is_starred) },
    { id: "public", predicate: (node) => node.access_scope === "public" },
    { id: "shared", predicate: (node) => node.access_scope === "shared" },
    { id: "private", predicate: (node) => node.access_scope === "private" },
    { id: "templates", predicate: (node) => node.is_template },
  ];

  return sections.map(({ id, predicate }) => ({
    id,
    title: SECTION_META[id].title,
    icon: SECTION_META[id].icon,
    nodes: projectTree(tree, predicate),
  }));
}


import { describe, expect, it } from "vitest";
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";

describe("tree-projection contract", () => {
  it("page_tree projection item 持续输出共享 contract 字段", () => {
    const items = buildPageTreeProjectionItems([
      {
        access_scope: "private",
        id: "root",
        workspace_id: "ws_1",
        title: "Root",
        parent_id: null,
        sort_order: 0,
        is_starred: false,
        is_template: false,
        created_at: "2026-04-18T00:00:00Z",
        updated_at: null,
        children: [],
      },
    ]);

    expect(items).toHaveLength(1);
    expect(items[0]).toMatchObject({
      rowId: "page:root",
      nodeId: "root",
      parentNodeId: null,
      projectionKind: "page_tree",
      expandable: false,
      expandedByDefault: true,
      iconHint: "page",
      resourceMeta: {
        resourceKind: "document",
        documentId: "root",
        iconHint: "page",
        extra: {
          source: {
            sourceKind: "convex_workspace",
            sourceUri: "convex://workspace/ws_1/documents/root",
            storageIdentity: "root",
            operationProfile: "convex_workspace",
          },
        },
      },
    });
    expect(items[0]?.capabilities).toContain("open");
  });
});


import { describe, expect, it } from "vitest";
import {
  buildPageTreeProjectionItems,
  buildPickerTreeItems,
  filterVisiblePageTreeProjectionItems,
} from "./tree-projection";

describe("buildPageTreeProjectionItems", () => {
  it("按当前树结构重建 parent/depth，而不是信任陈旧节点字段", () => {
    const rows = buildPageTreeProjectionItems([
      {
        access_scope: "private",
        id: "root",
        workspace_id: "w",
        title: "Root",
        parent_id: null,
        sort_order: 0,
        is_starred: false,
        is_template: false,
        created_at: "",
        updated_at: null,
        kernel: {
          nodeType: "page",
          depth: 0,
          position: 0,
          childCount: 1,
          expandedByDefault: true,
        },
        children: [
          {
            access_scope: "private",
            id: "child",
            workspace_id: "w",
            title: "Child",
            parent_id: null,
            sort_order: 9,
            is_starred: false,
            is_template: false,
            created_at: "",
            updated_at: null,
            kernel: {
              nodeType: "page",
              depth: 0,
              position: 9,
              childCount: 0,
              expandedByDefault: true,
            },
            children: [],
          },
        ],
      },
    ]);

    expect(rows.map((item) => ({
      nodeId: item.nodeId,
      parentNodeId: item.parentNodeId,
      depth: item.depth,
    }))).toEqual([
      { nodeId: "root", parentNodeId: null, depth: 0 },
      { nodeId: "child", parentNodeId: "root", depth: 1 },


},
        ],
      },
    ]);

    expect(rows.map((item) => ({
      nodeId: item.nodeId,
      parentNodeId: item.parentNodeId,
      depth: item.depth,
    }))).toEqual([
      { nodeId: "root", parentNodeId: null, depth: 0 },
      { nodeId: "child", parentNodeId: "root", depth: 1 },
    ]);
    expect(rows[1]?.node.parent_id).toBe("root");
    expect(rows[1]?.node.kernel?.depth).toBe(1);
  });
});

describe("filterVisiblePageTreeProjectionItems", () => {
  it("只返回当前展开路径上的可见页面", () => {
    const items = buildPageTreeProjectionItems([
      {
        access_scope: "private",
        id: "root",
        workspace_id: "w",
        title: "Root",
        parent_id: null,
        sort_order: 0,
        is_starred: false,
        is_template: false,
        created_at: "",
        updated_at: null,
        children: [
          {
            access_scope: "private",
            id: "child",
            workspace_id: "w",
            title: "Child",
            parent_id: "root",
            sort_order: 0,
            is_starred: false,
            is_template: false,
            created_at: "",
            updated_at: null,
            children: [
              {
                access_scope: "private",
                id: "leaf",
                workspace_id: "w",
                title: "Leaf",
                parent_id: "child",
                sort_order: 0,
                is_starred: false,
                is_template: false,
                created_at: "",
                updated_at: null,
                children: [],
              },
            ],
          },


workspace_id: "w",
                title: "Leaf",
                parent_id: "child",
                sort_order: 0,
                is_starred: false,
                is_template: false,
                created_at: "",
                updated_at: null,
                children: [],
              },
            ],
          },
        ],
      },
    ]);

    expect(filterVisiblePageTreeProjectionItems(items, new Set()).map((item) => item.nodeId)).toEqual(["root"]);
    expect(filterVisiblePageTreeProjectionItems(items, new Set(["root"])).map((item) => item.nodeId)).toEqual([
      "root",
      "child",
    ]);
    expect(
      filterVisiblePageTreeProjectionItems(items, new Set(["root", "child"])).map(
        (item) => item.nodeId,
      ),
    ).toEqual(["root", "child", "leaf"]);
  });
});

describe("buildPickerTreeItems", () => {
  it("复用 page_tree projection 生成 picker 列表", () => {
    const items = buildPageTreeProjectionItems([
      {
        access_scope: "private",
        id: "a",
        workspace_id: "w",
        title: "A",
        parent_id: null,
        sort_order: 0,
        is_starred: false,
        is_template: false,
        created_at: "",
        updated_at: null,
        children: [
          {
            access_scope: "private",
            id: "b",
            workspace_id: "w",
            title: null,
            parent_id: "a",
            sort_order: 0,
            is_starred: false,
            is_template: false,
            created_at: "",
            updated_at: null,
            children: [],
          },
        ],
      },
    ]);


title: null,
            parent_id: "a",
            sort_order: 0,
            is_starred: false,
            is_template: false,
            created_at: "",
            updated_at: null,
            children: [],
          },
        ],
      },
    ]);

    expect(buildPickerTreeItems(items, ["a"])).toEqual([
      { id: "b", title: "无标题", depth: 1 },
    ]);
  });
});


import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type {
  TreeProjectionCapability,
  TreeProjectionItemBase,
  TreeProjectionSourceMetadata,
} from "@/lib/tree-protocol";

export type PageTreeProjectionItem = {
  rowId: `page:${string}`;
} & TreeProjectionItemBase & {
  node: SidebarTreeNode;
};

export type PickerTreeItem = {
  id: string;
  title: string;
  depth: number;
};

function buildPageCapabilities(childCount: number): TreeProjectionCapability[] {
  const capabilities: TreeProjectionCapability[] = [
    "open",
    "drag",
    "drop",
    "select",
    "create-child",
    "rename",
    "archive",
    "restore",
    "context-menu",
    "reorder",
  ];
  if (childCount > 0) {
    capabilities.unshift("expand");
  }
  return capabilities;
}

function buildPageSourceMetadata(node: SidebarTreeNode): TreeProjectionSourceMetadata {
  return {
    sourceKind: "convex_workspace",
    sourceUri: `convex://workspace/${node.workspace_id}/documents/${node.id}`,
    relativePath: `documents/${node.id}`,
    storageIdentity: node.id,
    operationProfile: "convex_workspace",
  };
}

export function buildPageTreeProjectionItems(
  nodes: SidebarTreeNode[],
): PageTreeProjectionItem[] {
  const items: PageTreeProjectionItem[] = [];
  const visited = new Set<string>();

  const walk = (
    entries: SidebarTreeNode[],
    depth: number,
    parentNodeId: string | null,
  ) => {
    entries.forEach((node, index) => {


export function buildPageTreeProjectionItems(
  nodes: SidebarTreeNode[],
): PageTreeProjectionItem[] {
  const items: PageTreeProjectionItem[] = [];
  const visited = new Set<string>();

  const walk = (
    entries: SidebarTreeNode[],
    depth: number,
    parentNodeId: string | null,
  ) => {
    entries.forEach((node, index) => {
      if (visited.has(node.id)) {
        return;
      }
      visited.add(node.id);

      const childCount = node.children.length;
      items.push({
        rowId: `page:${node.id}`,
        nodeId: node.id,
        parentNodeId,
        nodeType: "page",
        projectionKind: "page_tree",
        depth,
        position: node.kernel?.position ?? node.sort_order ?? index,
        title: node.title ?? "无标题",
        childCount,
        expandable: childCount > 0,
        expandedByDefault: node.kernel?.expandedByDefault ?? true,
        capabilities: buildPageCapabilities(childCount),
        resourceMeta: {
          resourceKind: "document",
          documentId: node.id,
          workspaceId: node.workspace_id,
          iconHint: "page",
          extra: {
            source: buildPageSourceMetadata(node),
          },
        },
        iconHint: "page",
        node: {
          ...node,
          parent_id: parentNodeId,
          kernel: node.kernel
            ? {
                ...node.kernel,
                depth,
                position: node.kernel.position ?? node.sort_order ?? index,
                childCount,
              }
            : {
                nodeType: "page",
                depth,
                position: node.sort_order ?? index,
                childCount,
                expandedByDefault: true,
              },
        },
      });


position: node.kernel.position ?? node.sort_order ?? index,
                childCount,
              }
            : {
                nodeType: "page",
                depth,
                position: node.sort_order ?? index,
                childCount,
                expandedByDefault: true,
              },
        },
      });
      if (childCount > 0) {
        walk(node.children, depth + 1, node.id);
      }
    });
  };

  walk(nodes, 0, null);
  return items;
}

export function filterVisiblePageTreeProjectionItems(
  items: PageTreeProjectionItem[],
  expanded: Set<string>,
): PageTreeProjectionItem[] {
  const visible: PageTreeProjectionItem[] = [];
  const visibleIds = new Set<string>();

  items.forEach((item) => {
    if (!item.parentNodeId) {
      visible.push(item);
      visibleIds.add(item.nodeId);
      return;
    }

    if (!visibleIds.has(item.parentNodeId)) {
      return;
    }

    if (!expanded.has(item.parentNodeId)) {
      return;
    }

    visible.push(item);
    visibleIds.add(item.nodeId);
  });

  return visible;
}

export function buildPickerTreeItems(
  items: PageTreeProjectionItem[],
  excludeIds: Iterable<string> = [],
): PickerTreeItem[] {
  const excluded = new Set(excludeIds);
  return items
    .filter((item) => !excluded.has(item.nodeId))
    .map((item) => ({
      id: item.nodeId,


return visible;
}

export function buildPickerTreeItems(
  items: PageTreeProjectionItem[],
  excludeIds: Iterable<string> = [],
): PickerTreeItem[] {
  const excluded = new Set(excludeIds);
  return items
    .filter((item) => !excluded.has(item.nodeId))
    .map((item) => ({
      id: item.nodeId,
      title: item.title,
      depth: item.depth,
    }));
}


import { describe, expect, it } from "vitest";
import type {
  TreeProjectionItemBase,
  WorkspaceSource,
} from "@/lib/tree-protocol";

describe("tree-protocol", () => {
  it("冻结 WorkspaceSource 合同字段", () => {
    const source: WorkspaceSource = {
      sourceKind: "convex_workspace",
      rootUri: "convex://workspace/ws_1",
      workspaceId: "ws_1",
      capabilities: [
        "load-snapshot",
        "watch",
        "preflight-command",
        "execute-command",
        "resolve-page-aggregate",
      ],
    };

    expect(source.sourceKind).toBe("convex_workspace");
    expect(source.rootUri).toContain("workspace/ws_1");
    expect(source.capabilities).toContain("load-snapshot");
  });

  it("冻结共享 tree projection 字段与 file_tree 扩展语义", () => {
    const item: TreeProjectionItemBase = {
      nodeId: "doc_1",
      parentNodeId: null,
      nodeType: "page",
      projectionKind: "file_tree",
      title: "页面 1",
      depth: 0,
      position: 1,
      childCount: 2,
      expandable: true,
      expandedByDefault: true,
      capabilities: ["expand", "open", "drag", "drop", "select", "create-child"],
      resourceMeta: {
        resourceKind: "document",
        documentId: "doc_1",
        assetKind: "file",
        iconHint: "page",
        extra: {
          source: {
            sourceKind: "convex_workspace",
            sourceUri: "convex://workspace/ws_1",
            relativePath: "docs/doc_1.md",
            storageIdentity: "doc_1",
            operationProfile: "convex_workspace",
          },
        },
      },
      iconHint: "page",
    };

    expect(item.resourceMeta.resourceKind).toBe("document");
    expect(item.resourceMeta.assetKind).toBe("file");
    expect(item.resourceMeta.extra?.source).toMatchObject({


relativePath: "docs/doc_1.md",
            storageIdentity: "doc_1",
            operationProfile: "convex_workspace",
          },
        },
      },
      iconHint: "page",
    };

    expect(item.resourceMeta.resourceKind).toBe("document");
    expect(item.resourceMeta.assetKind).toBe("file");
    expect(item.resourceMeta.extra?.source).toMatchObject({
      sourceKind: "convex_workspace",
      sourceUri: "convex://workspace/ws_1",
      relativePath: "docs/doc_1.md",
      storageIdentity: "doc_1",
      operationProfile: "convex_workspace",
    });
    expect(item.capabilities).toContain("expand");
    expect(item.expandable).toBe(true);
    expect(item.iconHint).toBe("page");
  });
});


export type TreeProjectionKind = "sidebar_tree" | "page_tree" | "file_tree";

export type WorkspaceSourceKind = "local_folder" | "convex_workspace";

export type WorkspaceSourceCapability =
  | "load-snapshot"
  | "watch"
  | "preflight-command"
  | "execute-command"
  | "resolve-page-aggregate";

export type WorkspaceSource = {
  sourceKind: WorkspaceSourceKind;
  rootUri: string;
  workspaceId: string;
  capabilities: WorkspaceSourceCapability[];
};

export type TreeProjectionNodeType =
  | "workspace"
  | "folder"
  | "page"
  | "section"
  | "asset"
  | "mindmap"
  | "table"
  | "book"
  | "pdf"
  | "index";

export type TreeProjectionCapability =
  | "expand"
  | "open"
  | "drag"
  | "drop"
  | "select"
  | "create-child"
  | "rename"
  | "archive"
  | "restore"
  | "context-menu"
  | "reorder"
  | "open-asset"
  | "pick";

export type TreeProjectionResourceKind =
  | "workspace"
  | "document"
  | "index"
  | "asset"
  | "asset_folder"
  | "mindmap"
  | "table"
  | "book"
  | "pdf";

export type TreeProjectionAssetKind =
  | "file"
  | "mindmap"
  | "table"


| "index"
  | "asset"
  | "asset_folder"
  | "mindmap"
  | "table"
  | "book"
  | "pdf";

export type TreeProjectionAssetKind =
  | "file"
  | "mindmap"
  | "table"
  | "book"
  | "pdf"
  | "image"
  | "video"
  | "audio"
  | "unknown";

export type TreeProjectionSourceOperationProfile =
  | "local_readonly"
  | "convex_workspace";

export type TreeProjectionSourceMetadata = {
  sourceKind: WorkspaceSourceKind;
  sourceUri: string;
  relativePath?: string | null;
  storageIdentity?: string | null;
  operationProfile?: TreeProjectionSourceOperationProfile | null;
};

export type TreeProjectionResourceExtra = {
  source?: TreeProjectionSourceMetadata | null;
} & Record<string, unknown>;

export type TreeProjectionResourceMeta = {
  resourceKind: TreeProjectionResourceKind;
  documentId?: string | null;
  assetId?: string | null;
  workspaceId?: string | null;
  assetKind?: TreeProjectionAssetKind | null;
  iconHint?: string | null;
  extra?: TreeProjectionResourceExtra;
};

export type TreeProjectionItemBase = {
  nodeId: string;
  parentNodeId: string | null;
  nodeType: TreeProjectionNodeType;
  projectionKind: TreeProjectionKind;
  title: string;
  depth: number;
  position: number | null;
  childCount: number;
  expandable: boolean;
  expandedByDefault: boolean;
  capabilities: TreeProjectionCapability[];
  resourceMeta: TreeProjectionResourceMeta;
  iconHint: string | null;
};


nodeType: TreeProjectionNodeType;
  projectionKind: TreeProjectionKind;
  title: string;
  depth: number;
  position: number | null;
  childCount: number;
  expandable: boolean;
  expandedByDefault: boolean;
  capabilities: TreeProjectionCapability[];
  resourceMeta: TreeProjectionResourceMeta;
  iconHint: string | null;
};


import { describe, expect, it } from "vitest";
import { TREE_3000_ROUTE_BOUNDARY_MANIFEST } from "./tree-route-boundary";

describe("TREE_3000_ROUTE_BOUNDARY_MANIFEST", () => {
  it("固定 3000 route 的 Rust Web gateway owner 与 legacy compat 边界", () => {
    expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST).toMatchObject({
      schema: "mnote.tree.3000_route_boundary",
      schemaVersion: 1,
      publicEntry: "3000",
    });

    expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.routes).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          id: "tree.commands",
          role: "rust-owned",
          route: "/api/tree/commands",
          owner: "rust-web-gateway",
        }),
        expect.objectContaining({
          id: "tree.stream",
          role: "rust-owned",
          route: "/api/tree/stream",
          owner: "rust-web-gateway",
        }),
        expect.objectContaining({
          id: "tree.shell.debug",
          role: "compat-pending",
          route: "/api/tree/shell",
          owner: "next-app-router-legacy",
        }),
      ]),
    );
    expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.rustOwnedSemantics).toContain("tree.subtree.move");
    expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.nextThinProxyDuties).toContain(
      "legacy route alias 与 header 透传",
    );
    expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.browserSubstrateDuties).toContain(
      "新页面 scaffold 文件创建",
    );
    expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.forbiddenNextSemantics).toContain("树排序 canonical plan");
    expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.forbiddenNextSemantics).toContain(
      "主 tree command transport owner",
    );
  });
});


export type TreeRouteBoundaryRole =
  | "rust-owned"
  | "next-thin-proxy"
  | "legacy-thin-proxy"
  | "browser-substrate"
  | "compat-pending";

export type TreeRouteBoundaryItem = {
  id: string;
  role: TreeRouteBoundaryRole;
  route: string;
  owner: "rust-runtime" | "rust-web-gateway" | "next-app-router-legacy" | "convex-substrate";
  description: string;
};

export const TREE_3000_ROUTE_BOUNDARY_MANIFEST = {
  schema: "mnote.tree.3000_route_boundary",
  schemaVersion: 1,
  publicEntry: "3000",
  routes: [
    {
      id: "tree.commands",
      role: "rust-owned",
      route: "/api/tree/commands",
      owner: "rust-web-gateway",
      description:
        "浏览器公开树命令入口由 Rust Web gateway 拥有；legacy Next 仅保留 cookie/header compat 和浏览器 substrate 能力。",
    },
    {
      id: "tree.stream",
      role: "rust-owned",
      route: "/api/tree/stream",
      owner: "rust-web-gateway",
      description:
        "浏览器 SSE/polling 入口由 Rust Web gateway 拥有，legacy Next 只作为迁移期 alias。",
    },
    {
      id: "tree.shell.debug",
      role: "compat-pending",
      route: "/api/tree/shell",
      owner: "next-app-router-legacy",
      description:
        "仅服务显式 debug/internal runtime 验证；3000 主路径使用 same-origin inline host，不应默认请求 mnote-web:3104。",
    },
  ] satisfies TreeRouteBoundaryItem[],
  rustOwnedSemantics: [
    "tree.node.create",
    "tree.node.rename",
    "tree.subtree.move",
    "tree.node.archive",
    "tree.node.restore",
    "tree.node.purge",
    "tree.subtree.copy",
    "tree.node.embed",
  ],
  nextThinProxyDuties: [
    "cookie/auth 读取与 Convex client 获取",
    "workspace bootstrap",
    "legacy route alias 与 header 透传",
    "tree.subtree.move 的 sidebar snapshot preflight 数据采集",


"tree.subtree.move",
    "tree.node.archive",
    "tree.node.restore",
    "tree.node.purge",
    "tree.subtree.copy",
    "tree.node.embed",
  ],
  nextThinProxyDuties: [
    "cookie/auth 读取与 Convex client 获取",
    "workspace bootstrap",
    "legacy route alias 与 header 透传",
    "tree.subtree.move 的 sidebar snapshot preflight 数据采集",
  ],
  browserSubstrateDuties: [
    "新页面 scaffold 文件创建",
    "复制页面后的 mindmap 文件复制",
    "页面嵌入前读取目标内容、源页面标题与 anchor block，作为 Rust Page Aggregate embed plan 的 preflight substrate",
    "文件字节读取、upload URL、cookie/auth 等浏览器入口能力",
  ],
  compatPending: [] satisfies TreeRouteBoundaryItem[],
  forbiddenNextSemantics: [
    "树合法性判断",
    "树排序 canonical plan",
    "长期 streamDelta 主语义拼装",
    "tree shell renderer runtime",
    "tree.node.embed 的 pageReference block 结构与插入位置语义",
    "主 tree command transport owner",
    "主 tree stream transport owner",
  ],
} as const;


/**
 * 类型守卫和类型断言工具
 * 用于替代 `any` 类型，提供更安全的类型检查
 */

/**
 * 检查值是否为普通对象（非 null、非数组）
 */
export function isPlainObject(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
 * 检查值是否为字符串
 */
export function isString(value: unknown): value is string {
  return typeof value === "string";
}

/**
 * 检查值是否为数字（有限）
 */
export function isFiniteNumber(value: unknown): value is number {
  return typeof value === "number" && Number.isFinite(value);
}

/**
 * 检查值是否为数组
 */
export function isArray<T = unknown>(value: unknown, itemGuard?: (item: unknown) => item is T): value is T[] {
  if (!Array.isArray(value)) return false;
  if (itemGuard) {
    return value.every(itemGuard);
  }
  return true;
}

/**
 * 检查对象是否包含指定的属性
 */
export function hasProperty<K extends string>(
  obj: unknown,
  key: K,
): obj is Record<K, unknown> {
  return isPlainObject(obj) && key in obj;
}

/**
 * 检查对象是否包含多个指定的属性
 */
export function hasProperties<K extends string>(
  obj: unknown,
  keys: K[],
): obj is Record<K, unknown> {
  if (!isPlainObject(obj)) return false;
  return keys.every(key => key in obj);
}

/**
 * 从对象中安全地提取字符串属性


* 检查对象是否包含多个指定的属性
 */
export function hasProperties<K extends string>(
  obj: unknown,
  keys: K[],
): obj is Record<K, unknown> {
  if (!isPlainObject(obj)) return false;
  return keys.every(key => key in obj);
}

/**
 * 从对象中安全地提取字符串属性
 */
export function getStringProperty(obj: unknown, key: string, defaultValue: string = ""): string {
  if (!isPlainObject(obj)) return defaultValue;
  const value = obj[key];
  return isString(value) ? value : defaultValue;
}

/**
 * 从对象中安全地提取数字属性
 */
export function getNumberProperty(obj: unknown, key: string, defaultValue: number = 0): number {
  if (!isPlainObject(obj)) return defaultValue;
  const value = obj[key];
  return isFiniteNumber(value) ? value : defaultValue;
}

/**
 * 从对象中安全地提取布尔属性
 */
export function getBooleanProperty(obj: unknown, key: string, defaultValue: boolean = false): boolean {
  if (!isPlainObject(obj)) return defaultValue;
  const value = obj[key];
  return typeof value === "boolean" ? value : defaultValue;
}

/**
 * 从对象中安全地提取数组属性
 */
export function getArrayProperty<T = unknown>(
  obj: unknown,
  key: string,
  defaultValue: T[] = [],
): T[] {
  if (!isPlainObject(obj)) return defaultValue;
  const value = obj[key];
  return Array.isArray(value) ? value as T[] : defaultValue;
}

/**
 * 检查是否为 Supabase 行对象（包含 id 属性）
 */
export function isDatabaseRow(value: unknown): value is { id: string | number; [key: string]: unknown } {
  return isPlainObject(value) && ("id" in value);
}

/**
 * 检查是否为 Supabase 行数组
 */


}

/**
 * 检查是否为 Supabase 行对象（包含 id 属性）
 */
export function isDatabaseRow(value: unknown): value is { id: string | number; [key: string]: unknown } {
  return isPlainObject(value) && ("id" in value);
}

/**
 * 检查是否为 Supabase 行数组
 */
export function isDatabaseRowArray(value: unknown): value is Array<{ id: string | number; [key: string]: unknown }> {
  return isArray(value) && value.every(isDatabaseRow);
}

/**
 * 类型断言：确保值不为 null/undefined
 */
export function assertNotNullOrUndefined<T>(value: T | null | undefined, message?: string): T {
  if (value === null || value === undefined) {
    throw new Error(message ?? "值不能为 null 或 undefined");
  }
  return value;
}

/**
 * 将 unknown 转换为 Record<string, unknown>，如果类型不匹配则返回空对象
 */
export function toRecord(value: unknown): Record<string, unknown> {
  return isPlainObject(value) ? value : {};
}

/**
 * 安全地访问嵌套对象属性
 * @example getNestedValue(obj, 'a.b.c') === obj?.a?.b?.c
 */
export function getNestedValue<T = unknown>(
  obj: unknown,
  path: string,
  defaultValue?: T,
): T | undefined {
  const keys = path.split(".");
  let current: unknown = obj;

  for (const key of keys) {
    if (!isPlainObject(current)) {
      return defaultValue;
    }
    current = current[key];
  }

  return current as T ?? defaultValue;
}

/**
 * 检查响应是否为错误响应
 */
export function isErrorResponse(value: unknown): value is { error: string; details?: unknown } {
  return isPlainObject(value) && isString(value.error);


}
    current = current[key];
  }

  return current as T ?? defaultValue;
}

/**
 * 检查响应是否为错误响应
 */
export function isErrorResponse(value: unknown): value is { error: string; details?: unknown } {
  return isPlainObject(value) && isString(value.error);
}

/**
 * 从 unknown 类型中提取工具参数
 * 用于 AI agent 工具调用时的类型安全
 */
export function getToolArgs(args: unknown): Record<string, unknown> {
  if (isPlainObject(args)) {
    return args;
  }
  // 如果是数组或其他类型，返回空对象
  return {};
}

/**
 * 检查是否为 AI Agent 消息
 */
export function isAgentMessage(value: unknown): value is { role: "user" | "assistant"; content: string } {
  return (
    isPlainObject(value) &&
    (value.role === "user" || value.role === "assistant") &&
    isString(value.content)
  );
}

/**
 * 检查是否为 AI Agent 消息数组
 */
export function isAgentMessageArray(value: unknown): value is Array<{ role: "user" | "assistant"; content: string }> {
  return isArray(value) && value.every(isAgentMessage);
}


import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}


type TypedClient = {
  from: (table: string) => any;
};

interface WorkspaceMembershipRow {
  workspace_id: string;
  is_default: boolean;
  workspaces:
    | {
        id: string;
        name: string;
        type: "personal" | "team";
        icon_url: string | null;
      }
    | Array<{
        id: string;
        name: string;
        type: "personal" | "team";
        icon_url: string | null;
      }>
    | null;
}

export interface WorkspaceSummary {
  id: string;
  name: string;
  type: "personal" | "team";
  iconUrl: string | null;
  memberCount: number;
  isDefault: boolean;
}

export async function ensureDefaultWorkspace(client: TypedClient, userId: string, fallbackName: string): Promise<void> {
  const { data: memberships, error } = await client
    .from("workspace_members")
    .select("workspace_id")
    .eq("user_id", userId)
    .limit(1);

  if (error) {
    throw new Error(`获取工作空间成员信息失败：${error.message}`);
  }

  if (memberships && memberships.length > 0) {
    return;
  }

  const workspaceName = fallbackName.trim() ? `${fallbackName.trim()} 的空间` : "我的空间";

  const { data: workspace, error: workspaceError } = await client
    .from("workspaces")
    .insert({
      name: workspaceName,
      type: "personal",
      created_by: userId,
    })
    .select("id")
    .single();

  if (workspaceError || !workspace) {


const { data: workspace, error: workspaceError } = await client
    .from("workspaces")
    .insert({
      name: workspaceName,
      type: "personal",
      created_by: userId,
    })
    .select("id")
    .single();

  if (workspaceError || !workspace) {
    throw new Error(`创建默认工作空间失败：${workspaceError?.message ?? "未知错误"}`);
  }

  const { error: memberError } = await client.from("workspace_members").insert({
    workspace_id: workspace.id,
    user_id: userId,
    role: "owner",
    is_default: true,
  });

  if (memberError) {
    throw new Error(`创建工作空间成员失败：${memberError.message}`);
  }
}

export async function fetchWorkspaceSummaries(
  client: TypedClient,
  userId: string,
): Promise<{ workspaces: WorkspaceSummary[]; activeWorkspaceId: string }> {
  const { data: memberRows, error } = await client
    .from("workspace_members")
    .select("workspace_id,is_default,workspaces(id,name,type,icon_url)")
    .eq("user_id", userId)
    .order("created_at", { ascending: true });

  if (error) {
    throw new Error(`拉取工作空间列表失败：${error.message}`);
  }

  const rows: WorkspaceMembershipRow[] = (memberRows ?? []) as any;
  const workspaceIds = rows.map((row) => row.workspace_id);

  const memberCountMap: Record<string, number> = {};
  if (workspaceIds.length > 0) {
    const { data: memberCounts, error: countError } = await client
      .from("workspace_members")
      .select("workspace_id")
      .in("workspace_id", workspaceIds);

    if (countError) {
      throw new Error(`统计成员数失败：${countError.message}`);
    }

    const countRows = (memberCounts ?? []) as Array<{ workspace_id: string }>;
    countRows.forEach((item) => {
      const workspaceId = item.workspace_id;
      memberCountMap[workspaceId] = (memberCountMap[workspaceId] ?? 0) + 1;
    });


.select("workspace_id")
      .in("workspace_id", workspaceIds);

    if (countError) {
      throw new Error(`统计成员数失败：${countError.message}`);
    }

    const countRows = (memberCounts ?? []) as Array<{ workspace_id: string }>;
    countRows.forEach((item) => {
      const workspaceId = item.workspace_id;
      memberCountMap[workspaceId] = (memberCountMap[workspaceId] ?? 0) + 1;
    });
  }

  const summaries: WorkspaceSummary[] = rows
    .map((row) => {
      const workspace = Array.isArray(row.workspaces) ? row.workspaces[0] : row.workspaces;
      if (!workspace) {
        return null;
      }
      return {
        id: workspace.id,
        name: workspace.name,
        type: workspace.type,
        iconUrl: workspace.icon_url,
        memberCount: memberCountMap[workspace.id] ?? 1,
        isDefault: row.is_default,
      } satisfies WorkspaceSummary;
    })
    .filter(Boolean) as WorkspaceSummary[];

  const defaultWorkspace = summaries.find((workspace) => workspace.isDefault);
  const activeWorkspaceId = defaultWorkspace?.id ?? summaries[0]?.id ?? "";

  return {
    workspaces: summaries,
    activeWorkspaceId,
  };
}

export async function resolveActiveWorkspaceId(client: TypedClient, userId: string): Promise<string> {
  const { data, error } = await client
    .from("workspace_members")
    .select("workspace_id,is_default")
    .eq("user_id", userId)
    .order("is_default", { ascending: false })
    .order("created_at", { ascending: true })
    .limit(1);

  if (error) {
    throw new Error(`获取当前工作空间失败：${error.message}`);
  }

  return data?.[0]?.workspace_id ?? "";
}


"use client";

const isLocalHostname = (hostname: string) =>
  hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";

const base64UrlEncodeUtf8 = (input: string) => {
  const bytes = new TextEncoder().encode(input);
  let binary = "";
  bytes.forEach((b) => {
    binary += String.fromCharCode(b);
  });
  const b64 = btoa(binary);
  return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
};

/**
 * 将“仅本机可达”的 URL（例如 http://127.0.0.1:3210/api/storage/...）转换为浏览器可访问的地址。
 *
 * - 本地访问（页面本身是 localhost/127）时不改写
 * - 外网访问时，若目标是 localhost/127/host.docker.internal，则改为走 /api/onlyoffice/proxy 由 Next 服务端回源
 */
export const toBrowserAccessibleUrl = (rawUrl: string | null | undefined): string | null => {
  const input = String(rawUrl ?? "").trim();
  if (!input) return rawUrl ?? null;
  if (typeof window === "undefined") return input;

  try {
    if (input.startsWith("/api/onlyoffice/proxy")) return input;
    const pageHost = window.location.hostname;
    if (isLocalHostname(pageHost)) return input;

    const u = new URL(input);
    if (!isLocalHostname(u.hostname)) return input;

    const proxy = new URL("/api/onlyoffice/proxy", window.location.origin);
    proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
    return proxy.toString();
  } catch {
    return input;
  }
};


import { describe, expect, it } from "vitest";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";

const buildReq = () =>
  new Request("https://example.com/api/test", {
    headers: {
      "x-forwarded-proto": "https",
      "x-forwarded-host": "app.example.com",
    },
  });

const base64UrlEncodeUtf8 = (input: string) => {
  return Buffer.from(input, "utf8")
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/g, "");
};

describe("maybeProxyForBrowserUrl", () => {
  it("对 localhost URL 生成 proxy URL", () => {
    const req = buildReq();
    const raw = "http://localhost:3210/api/storage/xxx";
    const out = maybeProxyForBrowserUrl(req, raw);
    const expected = `https://app.example.com/api/onlyoffice/proxy?u=${base64UrlEncodeUtf8(raw)}`;
    expect(out).toBe(expected);
  });

  it("对与 Convex Origin host 匹配的 URL 生成 proxy URL", () => {
    process.env.CONVEX_SELF_HOSTED_URL = "http://backend:3210";
    const req = buildReq();
    const raw = "http://backend:3210/api/storage/yyy";
    const out = maybeProxyForBrowserUrl(req, raw);
    const expected = `https://app.example.com/api/onlyoffice/proxy?u=${base64UrlEncodeUtf8(raw)}`;
    expect(out).toBe(expected);
  });

  it("对外部 URL 不做处理", () => {
    process.env.CONVEX_SELF_HOSTED_URL = "http://backend:3210";
    const req = buildReq();
    const raw = "https://cdn.example.com/image.png";
    const out = maybeProxyForBrowserUrl(req, raw);
    expect(out).toBe(raw);
  });

  it("已经是 /api/onlyoffice/proxy 的 URL 不重复包裹", () => {
    const req = buildReq();
    const raw = "/api/onlyoffice/proxy?u=abc";
    const out = maybeProxyForBrowserUrl(req, raw);
    expect(out).toBe(raw);
  });
});


const base64UrlEncodeUtf8 = (input: string) => {
  return Buffer.from(input, "utf8")
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/g, "");
};

const getRequestOrigin = (request: Request) => {
  const xfProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
  const xfHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
  const host = xfHost || request.headers.get("host") || new URL(request.url).host;
  const protocol = (xfProto || new URL(request.url).protocol.replace(/:$/, "")) + ":";
  return `${protocol}//${host}`;
};

const isLocalHostname = (hostname: string) =>
  hostname === "127.0.0.1" ||
  hostname === "localhost" ||
  hostname === "host.docker.internal" ||
  hostname === "0.0.0.0";

const getConvexOriginHost = () => {
  const raw = (process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL ?? "").trim();
  if (!raw) return null;
  try {
    const u = new URL(raw);
    return u.hostname;
  } catch {
    return null;
  }
};

/**
 * 将“浏览器不可达”的内部回源 URL 包一层 `/api/onlyoffice/proxy?u=...`，避免前端直接请求内网地址。
 * 说明：该函数只做代理 URL 的生成；真正的 SSRF 防护在 `/api/onlyoffice/proxy` 内完成。
 */
export const maybeProxyForBrowserUrl = (request: Request, rawUrl: string) => {
  const input = String(rawUrl || "").trim();
  if (!input) return input;
  if (input.startsWith("/api/onlyoffice/proxy")) return input;

  try {
    const u = new URL(input);
    const convexHost = getConvexOriginHost();
    const shouldProxy = isLocalHostname(u.hostname) || (convexHost ? u.hostname === convexHost : false);
    if (!shouldProxy) return input;

    const origin = getRequestOrigin(request);
    const proxy = new URL("/api/onlyoffice/proxy", origin);
    proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
    return proxy.toString();
  } catch {
    return input;
  }
};


export const rewriteToPublicOrigin = (rawUrl: string, publicBaseUrl?: string) => {
  if (!publicBaseUrl) return rawUrl;
  const input = String(rawUrl || "").trim();
  if (!input) return input;

  try {
    const u = new URL(input);
    const pub = new URL(publicBaseUrl);

    // 说明：如果是 Supabase 的典型路径（storage/auth/rest/functions/realtime），即使来源域名不同
    // 也统一改为当前“公网可达”的 Supabase origin，避免历史/旧隧道域名导致不可访问。
    const isSupabasePath =
      u.pathname.startsWith("/storage/v1/") ||
      u.pathname.startsWith("/auth/v1/") ||
      u.pathname.startsWith("/rest/v1/") ||
      u.pathname.startsWith("/functions/v1/") ||
      u.pathname.startsWith("/realtime/v1/");
    if (isSupabasePath && u.host !== pub.host) {
      u.protocol = pub.protocol;
      u.host = pub.host;
      return u.toString();
    }

    const isLocalHost =
      u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "host.docker.internal";
    const isLikelyInternalPort = u.port === "18000";

    if (!isLocalHost && !isLikelyInternalPort) {
      return input;
    }

    // 统一改为公网可达的 supabase origin（协议 + host + 端口）
    u.protocol = pub.protocol;
    u.host = pub.host;

    return u.toString();
  } catch {
    return input;
  }
};


import type { SidebarInitialData } from "@/components/sidebar/types";
import {
  mapSidebarDatasetListQueryResultToInitialData,
  type SidebarDatasetListQueryResult,
} from "@/lib/sidebar-data";

export type TreeStreamKind = "snapshot" | "delta" | "resync" | "heartbeat";

export interface TreeStreamEnvelope {
  stream: string;
  workspaceId: string | null;
  rootNodeId: string | null;
  cursor: string | null;
  kind: TreeStreamKind;
  projection: string | null;
  data: unknown;
}

const TREE_STREAM_KINDS = new Set<TreeStreamKind>(["snapshot", "delta", "resync", "heartbeat"]);

function isRecord(value: unknown): value is Record<string, unknown> {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

function readString(value: unknown): string | null {
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}

function readKind(value: unknown): TreeStreamKind | null {
  if (typeof value !== "string") {
    return null;
  }
  const normalized = value.trim().toLowerCase();
  return TREE_STREAM_KINDS.has(normalized as TreeStreamKind)
    ? (normalized as TreeStreamKind)
    : null;
}

function readNestedPayload(record: Record<string, unknown>): unknown {
  for (const key of ["data", "payload", "snapshot", "dataset", "sidebar"]) {
    if (key in record) {
      return record[key];
    }
  }
  return record;
}

function looksLikeSidebarInitialData(value: unknown): value is SidebarInitialData {
  return (
    isRecord(value) &&
    typeof value.activeWorkspaceId === "string" &&
    Array.isArray(value.documents)
  );
}

function looksLikeSidebarDatasetListQueryResult(
  value: unknown,
): value is SidebarDatasetListQueryResult {
  return (
    isRecord(value) &&


return (
    isRecord(value) &&
    typeof value.activeWorkspaceId === "string" &&
    Array.isArray(value.documents)
  );
}

function looksLikeSidebarDatasetListQueryResult(
  value: unknown,
): value is SidebarDatasetListQueryResult {
  return (
    isRecord(value) &&
    typeof value.active_workspace_id === "string" &&
    Array.isArray(value.documents)
  );
}

export function buildWorkspaceTreeStreamUrl(
  workspaceId: string,
  cursor?: string | null,
): string {
  const baseOrigin =
    typeof window !== "undefined" ? window.location.origin : "http://127.0.0.1:3000";
  const url = new URL("/api/tree/events", baseOrigin);
  url.searchParams.set("workspaceId", workspaceId.trim());
  if (typeof cursor === "string" && cursor.trim()) {
    url.searchParams.set("cursor", cursor.trim());
  }
  return url.toString();
}

export function parseTreeStreamMessage(input: {
  rawData: string;
  eventType?: string | null;
}): TreeStreamEnvelope | null {
  let parsed: unknown;
  try {
    parsed = JSON.parse(input.rawData);
  } catch {
    return null;
  }

  if (!isRecord(parsed)) {
    return null;
  }

  const kind = readKind(parsed.kind) ?? readKind(input.eventType) ?? readKind(parsed.event);
  if (!kind) {
    return null;
  }

  return {
    stream: readString(parsed.stream) ?? "workspace",
    workspaceId: readString(parsed.workspaceId) ?? readString(parsed.workspace_id),
    rootNodeId: readString(parsed.rootNodeId) ?? readString(parsed.root_node_id),
    cursor: readString(parsed.cursor),
    kind,
    projection: readString(parsed.projection),
    data: readNestedPayload(parsed),
  };


return null;
  }

  return {
    stream: readString(parsed.stream) ?? "workspace",
    workspaceId: readString(parsed.workspaceId) ?? readString(parsed.workspace_id),
    rootNodeId: readString(parsed.rootNodeId) ?? readString(parsed.root_node_id),
    cursor: readString(parsed.cursor),
    kind,
    projection: readString(parsed.projection),
    data: readNestedPayload(parsed),
  };
}

export function normalizeTreeStreamSnapshot(data: unknown): SidebarInitialData | null {
  if (looksLikeSidebarInitialData(data)) {
    return data;
  }
  if (looksLikeSidebarDatasetListQueryResult(data)) {
    return mapSidebarDatasetListQueryResultToInitialData(data);
  }
  if (!isRecord(data)) {
    return null;
  }

  for (const key of ["snapshot", "dataset", "sidebar", "payload", "data"]) {
    if (!(key in data)) {
      continue;
    }
    const nested = normalizeTreeStreamSnapshot(data[key]);
    if (nested) {
      return nested;
    }
  }

  return null;
}


import { describe, expect, it, vi } from "vitest";
import { streamTreeFrames } from "./server";
import type { TreeStreamCommandLogCursorRow } from "./server";

function buildOverview(rows: TreeStreamCommandLogCursorRow[]) {
  return {
    command_logs: rows,
    domain_events: [],
    has_more: false,
    next_cursor: null,
    generated_at: "2026-04-24T00:00:00Z",
  };
}

async function collectFrames<T>(generator: AsyncGenerator<T>) {
  const frames: T[] = [];
  for await (const frame of generator) {
    frames.push(frame);
  }
  return frames;
}

describe("tree-stream/server", () => {
  it("workspace scope 首帧应发 snapshot，并固定 sidebar_tree + cursor", async () => {
    const frames = await collectFrames(
      streamTreeFrames({
        workspaceId: "ws_1",
        pollMs: 1,
        maxPolls: 0,
        loadOverview: vi.fn().mockResolvedValue(
          buildOverview([{ id: "clog_2", created_at: "2026-04-24T00:00:01Z" }]),
        ),
        loadSnapshot: vi.fn().mockResolvedValue({
          requestId: "req_1",
          traceId: "trace_1",
          data: { activeWorkspaceId: "ws_1", documents: [] },
          snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { items: [] } },
        }),
        sleep: vi.fn().mockResolvedValue(undefined),
      }),
    );

    expect(frames).toHaveLength(1);
    expect(frames[0]).toMatchObject({
      event: "snapshot",
      payload: {
        kind: "snapshot",
        stream: "workspace",
        workspaceId: "ws_1",
        rootNodeId: null,
        projection: "sidebar_tree",
        cursor: JSON.stringify({
          createdAt: "2026-04-24T00:00:01Z",
          id: "clog_2",
        }),
      },
    });
  });

  it("subtree scope 首帧应切到 subtree + page_tree", async () => {


workspaceId: "ws_1",
        rootNodeId: null,
        projection: "sidebar_tree",
        cursor: JSON.stringify({
          createdAt: "2026-04-24T00:00:01Z",
          id: "clog_2",
        }),
      },
    });
  });

  it("subtree scope 首帧应切到 subtree + page_tree", async () => {
    const frames = await collectFrames(
      streamTreeFrames({
        workspaceId: "ws_1",
        rootNodeId: "page_root",
        pollMs: 1,
        maxPolls: 0,
        loadOverview: vi.fn().mockResolvedValue(
          buildOverview([{ id: "clog_2", created_at: "2026-04-24T00:00:01Z" }]),
        ),
        loadSnapshot: vi.fn().mockResolvedValue({
          requestId: "req_subtree_1",
          traceId: "trace_subtree_1",
          data: { nodes: [] },
          snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { nodes: [] } },
        }),
        sleep: vi.fn().mockResolvedValue(undefined),
      }),
    );

    expect(frames).toHaveLength(1);
    expect(frames[0]).toMatchObject({
      event: "snapshot",
      payload: {
        kind: "snapshot",
        stream: "subtree",
        workspaceId: "ws_1",
        rootNodeId: "page_root",
        projection: "page_tree",
      },
    });
  });

  it("检测到 cursor 之后出现新命令时，应发 resync 而不是静默结束", async () => {
    const loadOverview = vi
      .fn()
      .mockResolvedValueOnce(
        buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
      )
      .mockResolvedValueOnce(
        buildOverview([
          { id: "clog_2", created_at: "2026-04-24T00:00:02Z" },
          { id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
        ]),
      );
    const loadSnapshot = vi
      .fn()
      .mockResolvedValueOnce({
        requestId: "req_1",


buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
      )
      .mockResolvedValueOnce(
        buildOverview([
          { id: "clog_2", created_at: "2026-04-24T00:00:02Z" },
          { id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
        ]),
      );
    const loadSnapshot = vi
      .fn()
      .mockResolvedValueOnce({
        requestId: "req_1",
        traceId: "trace_1",
        data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
        snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
      })
      .mockResolvedValueOnce({
        requestId: "req_2",
        traceId: "trace_2",
        data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }, { id: "page_2" }] },
        snapshot: {
          dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }, { id: "page_2" }] },
          tree: { items: [] },
        },
      });

    const frames = await collectFrames(
      streamTreeFrames({
        workspaceId: "ws_1",
        pollMs: 1,
        maxPolls: 1,
        loadOverview,
        loadSnapshot,
        sleep: vi.fn().mockResolvedValue(undefined),
      }),
    );

    expect(frames).toHaveLength(2);
    expect(frames[0]).toMatchObject({ event: "snapshot" });
    expect(frames[1]).toMatchObject({
      event: "resync",
      payload: {
        kind: "resync",
        stream: "workspace",
        workspaceId: "ws_1",
        projection: "sidebar_tree",
        cursor: JSON.stringify({
          createdAt: "2026-04-24T00:00:02Z",
          id: "clog_2",
        }),
      },
    });
    expect(loadSnapshot).toHaveBeenCalledTimes(2);
  });

  it("检测到带 streamDelta 的单条新命令时，应直接发 delta", async () => {
    const loadOverview = vi
      .fn()
      .mockResolvedValueOnce(
        buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),


id: "clog_2",
        }),
      },
    });
    expect(loadSnapshot).toHaveBeenCalledTimes(2);
  });

  it("检测到带 streamDelta 的单条新命令时，应直接发 delta", async () => {
    const loadOverview = vi
      .fn()
      .mockResolvedValueOnce(
        buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
      )
      .mockResolvedValueOnce(
        buildOverview([
          {
            id: "clog_2",
            created_at: "2026-04-24T00:00:02Z",
            command_name: "tree.node.archive",
            payload: {
              documentId: "page_2",
              streamDelta: {
                op: "remove_document",
                documentId: "page_2",
              },
            },
          },
          { id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
        ]),
      );
    const loadSnapshot = vi.fn().mockResolvedValue({
      requestId: "req_1",
      traceId: "trace_1",
      data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
      snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
    });

    const frames = await collectFrames(
      streamTreeFrames({
        workspaceId: "ws_1",
        pollMs: 1,
        maxPolls: 1,
        loadOverview,
        loadSnapshot,
        sleep: vi.fn().mockResolvedValue(undefined),
      }),
    );

    expect(frames).toHaveLength(2);
    expect(frames[1]).toMatchObject({
      event: "delta",
      payload: {
        kind: "delta",
        cursor: JSON.stringify({
          createdAt: "2026-04-24T00:00:02Z",
          id: "clog_2",
        }),
        data: {
          op: "remove_document",
          documentId: "page_2",


expect(frames).toHaveLength(2);
    expect(frames[1]).toMatchObject({
      event: "delta",
      payload: {
        kind: "delta",
        cursor: JSON.stringify({
          createdAt: "2026-04-24T00:00:02Z",
          id: "clog_2",
        }),
        data: {
          op: "remove_document",
          documentId: "page_2",
        },
      },
    });
    expect(loadSnapshot).toHaveBeenCalledTimes(1);
  });

  it("只有 domain event 推进时，也应刷新 cursor 并触发 resync", async () => {
    const loadOverview = vi
      .fn()
      .mockResolvedValueOnce(
        buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
      )
      .mockResolvedValueOnce({
        command_logs: [{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }],
        domain_events: [
          {
            event_id: "evt_2",
            created_at: "2026-04-24T00:00:02Z",
          },
        ],
        has_more: false,
        next_cursor: null,
        generated_at: "2026-04-24T00:00:02Z",
      });
    const loadSnapshot = vi
      .fn()
      .mockResolvedValueOnce({
        requestId: "req_1",
        traceId: "trace_1",
        data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
        snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
      })
      .mockResolvedValueOnce({
        requestId: "req_2",
        traceId: "trace_2",
        data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
        snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
      });

    const frames = await collectFrames(
      streamTreeFrames({
        workspaceId: "ws_1",
        pollMs: 1,
        maxPolls: 1,
        loadOverview,
        loadSnapshot,
        sleep: vi.fn().mockResolvedValue(undefined),
      }),


snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
      });

    const frames = await collectFrames(
      streamTreeFrames({
        workspaceId: "ws_1",
        pollMs: 1,
        maxPolls: 1,
        loadOverview,
        loadSnapshot,
        sleep: vi.fn().mockResolvedValue(undefined),
      }),
    );

    expect(frames).toHaveLength(2);
    expect(frames[1]).toMatchObject({
      event: "resync",
      payload: {
        kind: "resync",
        cursor: JSON.stringify({
          createdAt: "2026-04-24T00:00:02Z",
          id: "domain_event:evt_2",
        }),
      },
    });
    expect(loadSnapshot).toHaveBeenCalledTimes(2);
  });

  it("单条新 domain event 携带 streamDelta 时，应直接发 delta", async () => {
    const loadOverview = vi
      .fn()
      .mockResolvedValueOnce({
        command_logs: [],
        domain_events: [
          {
            event_id: "evt_1",
            created_at: "2026-04-24T00:00:01Z",
          },
        ],
        has_more: false,
        next_cursor: null,
        generated_at: "2026-04-24T00:00:01Z",
      })
      .mockResolvedValueOnce({
        command_logs: [],
        domain_events: [
          {
            event_id: "evt_2",
            created_at: "2026-04-24T00:00:02Z",
            payload: {
              command_name: "tree.node.rename",
              streamDelta: {
                op: "upsert_document",
                document: {
                  id: "page_2",
                  title: "新标题",
                },
              },
            },
          },


created_at: "2026-04-24T00:00:02Z",
            payload: {
              command_name: "tree.node.rename",
              streamDelta: {
                op: "upsert_document",
                document: {
                  id: "page_2",
                  title: "新标题",
                },
              },
            },
          },
          {
            event_id: "evt_1",
            created_at: "2026-04-24T00:00:01Z",
          },
        ],
        has_more: false,
        next_cursor: null,
        generated_at: "2026-04-24T00:00:02Z",
      });
    const loadSnapshot = vi.fn().mockResolvedValue({
      requestId: "req_1",
      traceId: "trace_1",
      data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
      snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
    });

    const frames = await collectFrames(
      streamTreeFrames({
        workspaceId: "ws_1",
        pollMs: 1,
        maxPolls: 1,
        loadOverview,
        loadSnapshot,
        sleep: vi.fn().mockResolvedValue(undefined),
      }),
    );

    expect(frames).toHaveLength(2);
    expect(frames[1]).toMatchObject({
      event: "delta",
      payload: {
        kind: "delta",
        cursor: JSON.stringify({
          createdAt: "2026-04-24T00:00:02Z",
          id: "domain_event:evt_2",
        }),
        data: {
          op: "upsert_document",
          document: {
            id: "page_2",
            title: "新标题",
          },
        },
      },
    });
    expect(loadSnapshot).toHaveBeenCalledTimes(1);
  });


data: {
          op: "upsert_document",
          document: {
            id: "page_2",
            title: "新标题",
          },
        },
      },
    });
    expect(loadSnapshot).toHaveBeenCalledTimes(1);
  });

  it("单条资源 domain event 携带 upsert_assets 时，应直接发 delta", async () => {
    const streamDelta = {
      op: "upsert_assets",
      upsertAssets: [
        {
          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",
          created_at: "2026-04-26T00:00:00Z",
          updated_at: "2026-04-26T00:00:00Z",
        },
      ],
    };
    const loadOverview = vi
      .fn()
      .mockResolvedValueOnce({
        command_logs: [],
        domain_events: [
          {
            event_id: "evt_1",
            created_at: "2026-04-24T00:00:01Z",
          },
        ],
        has_more: false,
        next_cursor: null,
        generated_at: "2026-04-24T00:00:01Z",
      })
      .mockResolvedValueOnce({
        command_logs: [],
        domain_events: [
          {
            event_id: "evt_2",
            created_at: "2026-04-24T00:00:02Z",
            payload: {
              schema: "mnote.tree.domain_event",
              eventType: "tree.resource.moved",
              streamDelta,
            },
          },
          {
            event_id: "evt_1",
            created_at: "2026-04-24T00:00:01Z",


{
            event_id: "evt_2",
            created_at: "2026-04-24T00:00:02Z",
            payload: {
              schema: "mnote.tree.domain_event",
              eventType: "tree.resource.moved",
              streamDelta,
            },
          },
          {
            event_id: "evt_1",
            created_at: "2026-04-24T00:00:01Z",
          },
        ],
        has_more: false,
        next_cursor: null,
        generated_at: "2026-04-24T00:00:02Z",
      });
    const loadSnapshot = vi.fn().mockResolvedValue({
      requestId: "req_1",
      traceId: "trace_1",
      data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
      snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
    });

    const frames = await collectFrames(
      streamTreeFrames({
        workspaceId: "ws_1",
        pollMs: 1,
        maxPolls: 1,
        loadOverview,
        loadSnapshot,
        sleep: vi.fn().mockResolvedValue(undefined),
      }),
    );

    expect(frames).toHaveLength(2);
    expect(frames[1]).toMatchObject({
      event: "delta",
      payload: {
        kind: "delta",
        data: streamDelta,
      },
    });
    expect(loadSnapshot).toHaveBeenCalledTimes(1);
  });

  it("单条新 domain event 缺少可识别 streamDelta 时，应保守回退 resync", async () => {
    const loadOverview = vi
      .fn()
      .mockResolvedValueOnce({
        command_logs: [],
        domain_events: [
          {
            event_id: "evt_1",
            created_at: "2026-04-24T00:00:01Z",
          },
        ],
        has_more: false,
        next_cursor: null,


const loadOverview = vi
      .fn()
      .mockResolvedValueOnce({
        command_logs: [],
        domain_events: [
          {
            event_id: "evt_1",
            created_at: "2026-04-24T00:00:01Z",
          },
        ],
        has_more: false,
        next_cursor: null,
        generated_at: "2026-04-24T00:00:01Z",
      })
      .mockResolvedValueOnce({
        command_logs: [],
        domain_events: [
          {
            event_id: "evt_2",
            created_at: "2026-04-24T00:00:02Z",
            payload: {
              schema: "mnote.tree.domain_event",
              schemaVersion: 1,
              eventType: "tree.node.unknown",
            },
          },
          {
            event_id: "evt_1",
            created_at: "2026-04-24T00:00:01Z",
          },
        ],
        has_more: false,
        next_cursor: null,
        generated_at: "2026-04-24T00:00:02Z",
      });
    const loadSnapshot = vi
      .fn()
      .mockResolvedValueOnce({
        requestId: "req_1",
        traceId: "trace_1",
        data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
        snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
      })
      .mockResolvedValueOnce({
        requestId: "req_2",
        traceId: "trace_2",
        data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_2" }] },
        snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_2" }] }, tree: { items: [] } },
      });

    const frames = await collectFrames(
      streamTreeFrames({
        workspaceId: "ws_1",
        pollMs: 1,
        maxPolls: 1,
        loadOverview,
        loadSnapshot,
        sleep: vi.fn().mockResolvedValue(undefined),
      }),
    );


});

    const frames = await collectFrames(
      streamTreeFrames({
        workspaceId: "ws_1",
        pollMs: 1,
        maxPolls: 1,
        loadOverview,
        loadSnapshot,
        sleep: vi.fn().mockResolvedValue(undefined),
      }),
    );

    expect(frames).toHaveLength(2);
    expect(frames[1]).toMatchObject({
      event: "resync",
      payload: {
        kind: "resync",
        cursor: JSON.stringify({
          createdAt: "2026-04-24T00:00:02Z",
          id: "domain_event:evt_2",
        }),
      },
    });
    expect(loadSnapshot).toHaveBeenCalledTimes(2);
  });

  it("同一命令的 command log 与 domain event 同时推进且 delta 一致时，应发一次 delta", async () => {
    const streamDelta = {
      op: "upsert_document",
      document: { id: "page_2", title: "同一标题" },
    };
    const loadOverview = vi
      .fn()
      .mockResolvedValueOnce(
        buildOverview([{ id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" }]),
      )
      .mockResolvedValueOnce({
        command_logs: [
          {
            id: "clog_2",
            command_id: "cmd_2",
            created_at: "2026-04-24T00:00:02Z",
            command_name: "tree.node.rename",
            payload: { streamDelta },
          },
          { id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" },
        ],
        domain_events: [
          {
            event_id: "evt_2",
            command_id: "cmd_2",
            created_at: "2026-04-24T00:00:02Z",
            payload: { streamDelta },
          },
        ],
        has_more: false,
        next_cursor: null,
        generated_at: "2026-04-24T00:00:02Z",
      });


domain_events: [
          {
            event_id: "evt_2",
            command_id: "cmd_2",
            created_at: "2026-04-24T00:00:02Z",
            payload: { streamDelta },
          },
        ],
        has_more: false,
        next_cursor: null,
        generated_at: "2026-04-24T00:00:02Z",
      });
    const loadSnapshot = vi.fn().mockResolvedValue({
      requestId: "req_1",
      traceId: "trace_1",
      data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
      snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
    });

    const frames = await collectFrames(
      streamTreeFrames({
        workspaceId: "ws_1",
        pollMs: 1,
        maxPolls: 1,
        loadOverview,
        loadSnapshot,
        sleep: vi.fn().mockResolvedValue(undefined),
      }),
    );

    expect(frames).toHaveLength(2);
    expect(frames[1]).toMatchObject({
      event: "delta",
      payload: {
        kind: "delta",
        cursor: JSON.stringify({
          createdAt: "2026-04-24T00:00:02Z",
          id: "clog_2",
        }),
        data: streamDelta,
      },
    });
    expect(loadSnapshot).toHaveBeenCalledTimes(1);
  });

  it("上一帧 cursor 来自 command log 时，应按时间排除旧 domain event 后再做双写去重", async () => {
    const streamDelta = {
      op: "move_document",
      documentId: "page_2",
      parentId: "page_1",
      sortOrder: 2,
    };
    const loadOverview = vi
      .fn()
      .mockResolvedValueOnce(
        buildOverview([{ id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" }]),
      )
      .mockResolvedValueOnce({
        command_logs: [
          {


documentId: "page_2",
      parentId: "page_1",
      sortOrder: 2,
    };
    const loadOverview = vi
      .fn()
      .mockResolvedValueOnce(
        buildOverview([{ id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" }]),
      )
      .mockResolvedValueOnce({
        command_logs: [
          {
            id: "clog_2",
            command_id: "cmd_2",
            created_at: "2026-04-24T00:00:02Z",
            command_name: "tree.subtree.move",
            payload: { streamDelta },
          },
          { id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" },
        ],
        domain_events: [
          {
            event_id: "evt_2",
            command_id: "cmd_2",
            created_at: "2026-04-24T00:00:02Z",
            payload: { streamDelta },
          },
          {
            event_id: "evt_1",
            command_id: "cmd_1",
            created_at: "2026-04-24T00:00:01Z",
            payload: {
              streamDelta: {
                op: "noop",
              },
            },
          },
        ],
        has_more: false,
        next_cursor: null,
        generated_at: "2026-04-24T00:00:02Z",
      });
    const loadSnapshot = vi.fn().mockResolvedValue({
      requestId: "req_1",
      traceId: "trace_1",
      data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
      snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
    });

    const frames = await collectFrames(
      streamTreeFrames({
        workspaceId: "ws_1",
        pollMs: 1,
        maxPolls: 1,
        loadOverview,
        loadSnapshot,
        sleep: vi.fn().mockResolvedValue(undefined),
      }),
    );