From 51241865f8c77827e8c88b88cd11082c743a7c88 Mon Sep 17 00:00:00 2001
From: Alex Yang <himself65@outlook.com>
Date: Thu, 16 May 2024 16:29:16 -0700
Subject: [PATCH] feat: improve BaseNode (#848)

---
 .github/workflows/test.yml                    |   3 +-
 package.json                                  |   1 +
 packages/core/.madgerc                        |   7 +
 packages/core/package.json                    |   1 -
 packages/core/src/Node.ts                     | 148 +++++++++++-------
 packages/core/src/Settings.ts                 |  17 +-
 .../core/src/indices/vectorStore/index.ts     |   2 +-
 packages/core/src/internal/decorator/node.ts  |  60 +++++++
 .../core/src/internal/settings/chunk-size.ts  |  19 +++
 .../src/storage/docStore/KVDocumentStore.ts   |  14 +-
 packages/core/src/storage/docStore/utils.ts   |  22 ++-
 packages/core/tests/Embedding.test.ts         |   6 +-
 packages/core/tests/Node.test.ts              |  57 ++++++-
 .../tests/indices/VectorStoreIndex.test.ts    |   9 +-
 packages/env/.madgerc                         |   7 +
 packages/env/jsr.json                         |   2 +-
 packages/env/package.json                     |   1 +
 pnpm-lock.yaml                                |   6 +-
 turbo.json                                    |   1 +
 19 files changed, 293 insertions(+), 90 deletions(-)
 create mode 100644 packages/core/.madgerc
 create mode 100644 packages/core/src/internal/decorator/node.ts
 create mode 100644 packages/core/src/internal/settings/chunk-size.ts
 create mode 100644 packages/env/.madgerc

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index e75558cc1..c068bf97a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -76,8 +76,7 @@ jobs:
       - name: Run Type Check
         run: pnpm run type-check
       - name: Run Circular Dependency Check
-        run: pnpm run circular-check
-        working-directory: ./packages/core
+        run: pnpm dlx turbo run circular-check
       - uses: actions/upload-artifact@v3
         if: failure()
         with:
diff --git a/package.json b/package.json
index 96fe34ef8..5185efc45 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
     "eslint-plugin-react": "7.34.1",
     "husky": "^9.0.11",
     "lint-staged": "^15.2.2",
+    "madge": "^7.0.0",
     "prettier": "^3.2.5",
     "prettier-plugin-organize-imports": "^3.2.4",
     "turbo": "^1.13.3",
diff --git a/packages/core/.madgerc b/packages/core/.madgerc
new file mode 100644
index 000000000..66f0c66c6
--- /dev/null
+++ b/packages/core/.madgerc
@@ -0,0 +1,7 @@
+{
+	"detectiveOptions": {
+		"ts": {
+			"skipTypeImports": true
+		}
+	}
+}
diff --git a/packages/core/package.json b/packages/core/package.json
index e53b2fe0b..1e6d3a39d 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -69,7 +69,6 @@
     "@swc/core": "^1.5.5",
     "concurrently": "^8.2.2",
     "glob": "^10.3.12",
-    "madge": "^7.0.0",
     "typescript": "^5.4.5"
   },
   "engines": {
diff --git a/packages/core/src/Node.ts b/packages/core/src/Node.ts
index 8d8d045ab..820f464df 100644
--- a/packages/core/src/Node.ts
+++ b/packages/core/src/Node.ts
@@ -1,5 +1,5 @@
 import { createSHA256, path, randomUUID } from "@llamaindex/env";
-import _ from "lodash";
+import { chunkSizeCheck, lazyInitHash } from "./internal/decorator/node.js";
 
 export enum NodeRelationship {
   SOURCE = "SOURCE",
@@ -37,6 +37,16 @@ export type RelatedNodeType<T extends Metadata = Metadata> =
   | RelatedNodeInfo<T>
   | RelatedNodeInfo<T>[];
 
+export type BaseNodeParams<T extends Metadata = Metadata> = {
+  id_?: string;
+  metadata?: T;
+  excludedEmbedMetadataKeys?: string[];
+  excludedLlmMetadataKeys?: string[];
+  relationships?: Partial<Record<NodeRelationship, RelatedNodeType<T>>>;
+  hash?: string;
+  embedding?: number[];
+};
+
 /**
  * Generic abstract class for retrievable nodes
  */
@@ -47,21 +57,37 @@ export abstract class BaseNode<T extends Metadata = Metadata> {
    *
    * Set to a UUID by default.
    */
-  id_: string = randomUUID();
+  id_: string;
   embedding?: number[];
 
   // Metadata fields
-  metadata: T = {} as T;
-  excludedEmbedMetadataKeys: string[] = [];
-  excludedLlmMetadataKeys: string[] = [];
-  relationships: Partial<Record<NodeRelationship, RelatedNodeType<T>>> = {};
-  hash: string = "";
-
-  constructor(init?: Partial<BaseNode<T>>) {
-    Object.assign(this, init);
-  }
-
-  abstract getType(): ObjectType;
+  metadata: T;
+  excludedEmbedMetadataKeys: string[];
+  excludedLlmMetadataKeys: string[];
+  relationships: Partial<Record<NodeRelationship, RelatedNodeType<T>>>;
+
+  @lazyInitHash
+  accessor hash: string = "";
+
+  protected constructor(init?: BaseNodeParams<T>) {
+    const {
+      id_,
+      metadata,
+      excludedEmbedMetadataKeys,
+      excludedLlmMetadataKeys,
+      relationships,
+      hash,
+      embedding,
+    } = init || {};
+    this.id_ = id_ ?? randomUUID();
+    this.metadata = metadata ?? ({} as T);
+    this.excludedEmbedMetadataKeys = excludedEmbedMetadataKeys ?? [];
+    this.excludedLlmMetadataKeys = excludedLlmMetadataKeys ?? [];
+    this.relationships = relationships ?? {};
+    this.embedding = embedding;
+  }
+
+  abstract get type(): ObjectType;
 
   abstract getContent(metadataMode: MetadataMode): string;
   abstract getMetadataStr(metadataMode: MetadataMode): string;
@@ -146,7 +172,12 @@ export abstract class BaseNode<T extends Metadata = Metadata> {
    * @see toMutableJSON - use to return a mutable JSON instead
    */
   toJSON(): Record<string, any> {
-    return { ...this, type: this.getType() };
+    return {
+      ...this,
+      type: this.type,
+      // hash is an accessor property, so it's not included in the rest operator
+      hash: this.hash,
+    };
   }
 
   clone(): BaseNode {
@@ -159,32 +190,43 @@ export abstract class BaseNode<T extends Metadata = Metadata> {
    * @return {Record<string, any>} - The JSON representation of the object.
    */
   toMutableJSON(): Record<string, any> {
-    return _.cloneDeep(this.toJSON());
+    return structuredClone(this.toJSON());
   }
 }
 
+export type TextNodeParams<T extends Metadata = Metadata> =
+  BaseNodeParams<T> & {
+    text?: string;
+    textTemplate?: string;
+    startCharIdx?: number;
+    endCharIdx?: number;
+    metadataSeparator?: string;
+  };
+
 /**
  * TextNode is the default node type for text. Most common node type in LlamaIndex.TS
  */
 export class TextNode<T extends Metadata = Metadata> extends BaseNode<T> {
-  text: string = "";
-  textTemplate: string = "";
+  text: string;
+  textTemplate: string;
 
   startCharIdx?: number;
   endCharIdx?: number;
   // textTemplate: NOTE write your own formatter if needed
   // metadataTemplate: NOTE write your own formatter if needed
-  metadataSeparator: string = "\n";
+  metadataSeparator: string;
 
-  constructor(init?: Partial<TextNode<T>>) {
+  constructor(init: TextNodeParams<T> = {}) {
     super(init);
-    Object.assign(this, init);
-
-    if (new.target === TextNode) {
-      // Don't generate the hash repeatedly so only do it if this is
-      // constructing the derived class
-      this.hash = init?.hash ?? this.generateHash();
+    const { text, textTemplate, startCharIdx, endCharIdx, metadataSeparator } =
+      init;
+    this.text = text ?? "";
+    this.textTemplate = textTemplate ?? "";
+    if (startCharIdx) {
+      this.startCharIdx = startCharIdx;
     }
+    this.endCharIdx = endCharIdx;
+    this.metadataSeparator = metadataSeparator ?? "\n";
   }
 
   /**
@@ -194,7 +236,7 @@ export class TextNode<T extends Metadata = Metadata> extends BaseNode<T> {
    */
   generateHash() {
     const hashFunction = createSHA256();
-    hashFunction.update(`type=${this.getType()}`);
+    hashFunction.update(`type=${this.type}`);
     hashFunction.update(
       `startCharIdx=${this.startCharIdx} endCharIdx=${this.endCharIdx}`,
     );
@@ -202,10 +244,11 @@ export class TextNode<T extends Metadata = Metadata> extends BaseNode<T> {
     return hashFunction.digest();
   }
 
-  getType(): ObjectType {
+  get type() {
     return ObjectType.TEXT;
   }
 
+  @chunkSizeCheck
   getContent(metadataMode: MetadataMode = MetadataMode.NONE): string {
     const metadataStr = this.getMetadataStr(metadataMode).trim();
     return `${metadataStr}\n\n${this.text}`.trim();
@@ -246,19 +289,21 @@ export class TextNode<T extends Metadata = Metadata> extends BaseNode<T> {
   }
 }
 
+export type IndexNodeParams<T extends Metadata = Metadata> =
+  TextNodeParams<T> & {
+    indexId: string;
+  };
+
 export class IndexNode<T extends Metadata = Metadata> extends TextNode<T> {
-  indexId: string = "";
+  indexId: string;
 
-  constructor(init?: Partial<IndexNode<T>>) {
+  constructor(init?: IndexNodeParams<T>) {
     super(init);
-    Object.assign(this, init);
-
-    if (new.target === IndexNode) {
-      this.hash = init?.hash ?? this.generateHash();
-    }
+    const { indexId } = init || {};
+    this.indexId = indexId ?? "";
   }
 
-  getType(): ObjectType {
+  get type() {
     return ObjectType.INDEX;
   }
 }
@@ -267,16 +312,11 @@ export class IndexNode<T extends Metadata = Metadata> extends TextNode<T> {
  * A document is just a special text node with a docId.
  */
 export class Document<T extends Metadata = Metadata> extends TextNode<T> {
-  constructor(init?: Partial<Document<T>>) {
+  constructor(init?: TextNodeParams<T>) {
     super(init);
-    Object.assign(this, init);
-
-    if (new.target === Document) {
-      this.hash = init?.hash ?? this.generateHash();
-    }
   }
 
-  getType() {
+  get type() {
     return ObjectType.DOCUMENT;
   }
 }
@@ -303,21 +343,21 @@ export function jsonToNode(json: any, type?: ObjectType) {
 
 export type ImageType = string | Blob | URL;
 
-export type ImageNodeConstructorProps<T extends Metadata> = Pick<
-  ImageNode<T>,
-  "image" | "id_"
-> &
-  Partial<ImageNode<T>>;
+export type ImageNodeParams<T extends Metadata = Metadata> =
+  TextNodeParams<T> & {
+    image: ImageType;
+  };
 
 export class ImageNode<T extends Metadata = Metadata> extends TextNode<T> {
   image: ImageType; // image as blob
 
-  constructor(init: ImageNodeConstructorProps<T>) {
+  constructor(init: ImageNodeParams<T>) {
     super(init);
-    this.image = init.image;
+    const { image } = init;
+    this.image = image;
   }
 
-  getType(): ObjectType {
+  get type() {
     return ObjectType.IMAGE;
   }
 
@@ -360,15 +400,11 @@ export class ImageNode<T extends Metadata = Metadata> extends TextNode<T> {
 }
 
 export class ImageDocument<T extends Metadata = Metadata> extends ImageNode<T> {
-  constructor(init: ImageNodeConstructorProps<T>) {
+  constructor(init: ImageNodeParams<T>) {
     super(init);
-
-    if (new.target === ImageDocument) {
-      this.hash = init?.hash ?? this.generateHash();
-    }
   }
 
-  getType() {
+  get type() {
     return ObjectType.IMAGE_DOCUMENT;
   }
 }
diff --git a/packages/core/src/Settings.ts b/packages/core/src/Settings.ts
index 6852fffcc..7b2d751cc 100644
--- a/packages/core/src/Settings.ts
+++ b/packages/core/src/Settings.ts
@@ -13,6 +13,11 @@ import {
   setCallbackManager,
   withCallbackManager,
 } from "./internal/settings/CallbackManager.js";
+import {
+  getChunkSize,
+  setChunkSize,
+  withChunkSize,
+} from "./internal/settings/chunk-size.js";
 import type { LLM } from "./llm/types.js";
 import type { NodeParser } from "./nodeParsers/types.js";
 
@@ -41,14 +46,12 @@ class GlobalSettings implements Config {
   #promptHelper: PromptHelper | null = null;
   #embedModel: BaseEmbedding | null = null;
   #nodeParser: NodeParser | null = null;
-  #chunkSize?: number;
   #chunkOverlap?: number;
 
   #llmAsyncLocalStorage = new AsyncLocalStorage<LLM>();
   #promptHelperAsyncLocalStorage = new AsyncLocalStorage<PromptHelper>();
   #embedModelAsyncLocalStorage = new AsyncLocalStorage<BaseEmbedding>();
   #nodeParserAsyncLocalStorage = new AsyncLocalStorage<NodeParser>();
-  #chunkSizeAsyncLocalStorage = new AsyncLocalStorage<number>();
   #chunkOverlapAsyncLocalStorage = new AsyncLocalStorage<number>();
   #promptAsyncLocalStorage = new AsyncLocalStorage<PromptConfig>();
 
@@ -115,8 +118,8 @@ class GlobalSettings implements Config {
   get nodeParser(): NodeParser {
     if (this.#nodeParser === null) {
       this.#nodeParser = new SimpleNodeParser({
-        chunkSize: this.#chunkSize,
-        chunkOverlap: this.#chunkOverlap,
+        chunkSize: this.chunkSize,
+        chunkOverlap: this.chunkOverlap,
       });
     }
 
@@ -147,15 +150,15 @@ class GlobalSettings implements Config {
   }
 
   set chunkSize(chunkSize: number | undefined) {
-    this.#chunkSize = chunkSize;
+    setChunkSize(chunkSize);
   }
 
   get chunkSize(): number | undefined {
-    return this.#chunkSizeAsyncLocalStorage.getStore() ?? this.#chunkSize;
+    return getChunkSize();
   }
 
   withChunkSize<Result>(chunkSize: number, fn: () => Result): Result {
-    return this.#chunkSizeAsyncLocalStorage.run(chunkSize, fn);
+    return withChunkSize(chunkSize, fn);
   }
 
   get chunkOverlap(): number | undefined {
diff --git a/packages/core/src/indices/vectorStore/index.ts b/packages/core/src/indices/vectorStore/index.ts
index 2dc5922c8..d96cb3a05 100644
--- a/packages/core/src/indices/vectorStore/index.ts
+++ b/packages/core/src/indices/vectorStore/index.ts
@@ -311,7 +311,7 @@ export class VectorStoreIndex extends BaseIndex<IndexDict> {
     // NOTE: if the vector store keeps text,
     // we only need to add image and index nodes
     for (let i = 0; i < nodes.length; ++i) {
-      const type = nodes[i].getType();
+      const { type } = nodes[i];
       if (
         !vectorStore.storesText ||
         type === ObjectType.INDEX ||
diff --git a/packages/core/src/internal/decorator/node.ts b/packages/core/src/internal/decorator/node.ts
new file mode 100644
index 000000000..be52eac9f
--- /dev/null
+++ b/packages/core/src/internal/decorator/node.ts
@@ -0,0 +1,60 @@
+import { getEnv } from "@llamaindex/env";
+import type { BaseNode } from "../../Node.js";
+import { getChunkSize } from "../settings/chunk-size.js";
+
+const emitOnce = false;
+
+export function chunkSizeCheck(
+  contentGetter: () => string,
+  _context: ClassMethodDecoratorContext | ClassGetterDecoratorContext,
+) {
+  return function <Node extends BaseNode>(this: Node) {
+    const content = contentGetter.call(this);
+    const chunkSize = getChunkSize();
+    const enableChunkSizeCheck = getEnv("ENABLE_CHUNK_SIZE_CHECK") === "true";
+    if (
+      enableChunkSizeCheck &&
+      chunkSize !== undefined &&
+      content.length > chunkSize
+    ) {
+      console.warn(
+        `Node (${this.id_}) is larger than chunk size: ${content.length}`,
+      );
+      if (!emitOnce) {
+        console.warn(
+          "Will truncate the content if it is larger than chunk size",
+        );
+        console.warn("If you want to disable this behavior:");
+        console.warn("  1. Set Settings.chunkSize = undefined");
+        console.warn("  2. Set Settings.chunkSize to a larger value");
+        console.warn(
+          "  3. Change the way of splitting content into smaller chunks",
+        );
+      }
+      return content.slice(0, chunkSize);
+    }
+    return content;
+  };
+}
+
+export function lazyInitHash(
+  value: ClassAccessorDecoratorTarget<BaseNode, string>,
+  _context: ClassAccessorDecoratorContext,
+): ClassAccessorDecoratorResult<BaseNode, string> {
+  return {
+    get() {
+      const oldValue = value.get.call(this);
+      if (oldValue === "") {
+        const hash = this.generateHash();
+        value.set.call(this, hash);
+      }
+      return value.get.call(this);
+    },
+    set(newValue: string) {
+      value.set.call(this, newValue);
+    },
+    init(value: string): string {
+      return value;
+    },
+  };
+}
diff --git a/packages/core/src/internal/settings/chunk-size.ts b/packages/core/src/internal/settings/chunk-size.ts
new file mode 100644
index 000000000..88d984907
--- /dev/null
+++ b/packages/core/src/internal/settings/chunk-size.ts
@@ -0,0 +1,19 @@
+import { AsyncLocalStorage } from "@llamaindex/env";
+
+const chunkSizeAsyncLocalStorage = new AsyncLocalStorage<number | undefined>();
+const globalChunkSize: number | null = null;
+
+export function getChunkSize(): number | undefined {
+  return globalChunkSize ?? chunkSizeAsyncLocalStorage.getStore();
+}
+
+export function setChunkSize(chunkSize: number | undefined) {
+  chunkSizeAsyncLocalStorage.enterWith(chunkSize);
+}
+
+export function withChunkSize<Result>(
+  embeddedModel: number,
+  fn: () => Result,
+): Result {
+  return chunkSizeAsyncLocalStorage.run(embeddedModel, fn);
+}
diff --git a/packages/core/src/storage/docStore/KVDocumentStore.ts b/packages/core/src/storage/docStore/KVDocumentStore.ts
index ac644faba..42c5be8f3 100644
--- a/packages/core/src/storage/docStore/KVDocumentStore.ts
+++ b/packages/core/src/storage/docStore/KVDocumentStore.ts
@@ -5,7 +5,7 @@ import { DEFAULT_NAMESPACE } from "../constants.js";
 import type { BaseKVStore } from "../kvStore/types.js";
 import type { RefDocInfo } from "./types.js";
 import { BaseDocumentStore } from "./types.js";
-import { docToJson, jsonToDoc } from "./utils.js";
+import { docToJson, isValidDocJson, jsonToDoc } from "./utils.js";
 
 type DocMetaData = { docHash: string; refDocId?: string };
 
@@ -27,7 +27,12 @@ export class KVDocumentStore extends BaseDocumentStore {
     const jsonDict = await this.kvstore.getAll(this.nodeCollection);
     const docs: Record<string, BaseNode> = {};
     for (const key in jsonDict) {
-      docs[key] = jsonToDoc(jsonDict[key] as Record<string, any>);
+      const value = jsonDict[key];
+      if (isValidDocJson(value)) {
+        docs[key] = jsonToDoc(value);
+      } else {
+        console.warn(`Invalid JSON for docId ${key}`);
+      }
     }
     return docs;
   }
@@ -51,7 +56,7 @@ export class KVDocumentStore extends BaseDocumentStore {
       await this.kvstore.put(nodeKey, data, this.nodeCollection);
       const metadata: DocMetaData = { docHash: doc.hash };
 
-      if (doc.getType() === ObjectType.TEXT && doc.sourceNode !== undefined) {
+      if (doc.type === ObjectType.TEXT && doc.sourceNode !== undefined) {
         const refDocInfo = (await this.getRefDocInfo(
           doc.sourceNode.nodeId,
         )) || {
@@ -86,6 +91,9 @@ export class KVDocumentStore extends BaseDocumentStore {
         return;
       }
     }
+    if (!isValidDocJson(json)) {
+      throw new Error(`Invalid JSON for docId ${docId}`);
+    }
     return jsonToDoc(json);
   }
 
diff --git a/packages/core/src/storage/docStore/utils.ts b/packages/core/src/storage/docStore/utils.ts
index 6eccaaebf..ca1290768 100644
--- a/packages/core/src/storage/docStore/utils.ts
+++ b/packages/core/src/storage/docStore/utils.ts
@@ -4,14 +4,28 @@ import { Document, ObjectType, TextNode } from "../../Node.js";
 const TYPE_KEY = "__type__";
 const DATA_KEY = "__data__";
 
-export function docToJson(doc: BaseNode): Record<string, any> {
+type DocJson = {
+  [TYPE_KEY]: ObjectType;
+  [DATA_KEY]: string;
+};
+
+export function isValidDocJson(docJson: any): docJson is DocJson {
+  return (
+    typeof docJson === "object" &&
+    docJson !== null &&
+    docJson[TYPE_KEY] !== undefined &&
+    docJson[DATA_KEY] !== undefined
+  );
+}
+
+export function docToJson(doc: BaseNode): DocJson {
   return {
-    [DATA_KEY]: JSON.stringify(doc),
-    [TYPE_KEY]: doc.getType(),
+    [DATA_KEY]: JSON.stringify(doc.toJSON()),
+    [TYPE_KEY]: doc.type,
   };
 }
 
-export function jsonToDoc(docDict: Record<string, any>): BaseNode {
+export function jsonToDoc(docDict: DocJson): BaseNode {
   const docType = docDict[TYPE_KEY];
   const dataDict = JSON.parse(docDict[DATA_KEY]);
   let doc: BaseNode;
diff --git a/packages/core/tests/Embedding.test.ts b/packages/core/tests/Embedding.test.ts
index ab863ead1..a70297c97 100644
--- a/packages/core/tests/Embedding.test.ts
+++ b/packages/core/tests/Embedding.test.ts
@@ -1,8 +1,4 @@
-import {
-  OpenAIEmbedding,
-  SimilarityType,
-  similarity,
-} from "llamaindex/embeddings/index";
+import { OpenAIEmbedding, SimilarityType, similarity } from "llamaindex";
 import { beforeAll, describe, expect, test } from "vitest";
 import { mockEmbeddingModel } from "./utility/mockOpenAI.js";
 
diff --git a/packages/core/tests/Node.test.ts b/packages/core/tests/Node.test.ts
index 34a65a2c2..0bbfab42a 100644
--- a/packages/core/tests/Node.test.ts
+++ b/packages/core/tests/Node.test.ts
@@ -1,6 +1,26 @@
-import { TextNode } from "llamaindex/Node";
+import { Document, TextNode } from "llamaindex/Node";
 import { beforeEach, describe, expect, test } from "vitest";
 
+describe("Document", () => {
+  let document: Document;
+
+  beforeEach(() => {
+    document = new Document({ text: "Hello World" });
+  });
+
+  test("should generate a hash", () => {
+    expect(document.hash).toMatchInlineSnapshot(
+      `"1mkNkQC30mZlBBG48DNuG2WSKcTQ32DImC+4JUoVijg="`,
+    );
+  });
+
+  test("clone should have the same hash", () => {
+    const hash = document.hash;
+    const clone = document.clone();
+    expect(clone.hash).toBe(hash);
+  });
+});
+
 describe("TextNode", () => {
   let node: TextNode;
 
@@ -9,7 +29,9 @@ describe("TextNode", () => {
   });
 
   test("should generate a hash", () => {
-    expect(node.hash).toBe("nTSKdUTYqR52MPv/brvb4RTGeqedTEqG9QN8KSAj2Do=");
+    expect(node.hash).toMatchInlineSnapshot(
+      `"nTSKdUTYqR52MPv/brvb4RTGeqedTEqG9QN8KSAj2Do="`,
+    );
   });
 
   test("clone should have the same hash", () => {
@@ -17,4 +39,35 @@ describe("TextNode", () => {
     const clone = node.clone();
     expect(clone.hash).toBe(hash);
   });
+
+  test("node toJSON should keep the same", () => {
+    node.metadata.something = 1;
+    node.metadata.somethingElse = "2";
+    expect(node.toJSON()).toMatchInlineSnapshot(
+      {
+        id_: expect.any(String),
+      },
+      `
+      {
+        "embedding": undefined,
+        "endCharIdx": undefined,
+        "excludedEmbedMetadataKeys": [],
+        "excludedLlmMetadataKeys": [],
+        "hash": "nTSKdUTYqR52MPv/brvb4RTGeqedTEqG9QN8KSAj2Do=",
+        "id_": Any<String>,
+        "metadata": {
+          "something": 1,
+          "somethingElse": "2",
+        },
+        "metadataSeparator": "
+      ",
+        "relationships": {},
+        "startCharIdx": undefined,
+        "text": "Hello World",
+        "textTemplate": "",
+        "type": "TEXT",
+      }
+    `,
+    );
+  });
 });
diff --git a/packages/core/tests/indices/VectorStoreIndex.test.ts b/packages/core/tests/indices/VectorStoreIndex.test.ts
index 1537eba40..e2578adde 100644
--- a/packages/core/tests/indices/VectorStoreIndex.test.ts
+++ b/packages/core/tests/indices/VectorStoreIndex.test.ts
@@ -5,8 +5,7 @@ import {
   storageContextFromDefaults,
 } from "llamaindex";
 import { DocStoreStrategy } from "llamaindex/ingestion/strategies/index";
-import { rmSync } from "node:fs";
-import { mkdtemp } from "node:fs/promises";
+import { mkdtemp, rm } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { afterAll, beforeAll, describe, expect, test } from "vitest";
@@ -15,7 +14,7 @@ const testDir = await mkdtemp(join(tmpdir(), "test-"));
 
 import { mockServiceContext } from "../utility/mockServiceContext.js";
 
-describe.sequential("VectorStoreIndex", () => {
+describe("VectorStoreIndex", () => {
   let serviceContext: ServiceContext;
   let storageContext: StorageContext;
   let testStrategy: (
@@ -57,7 +56,7 @@ describe.sequential("VectorStoreIndex", () => {
     expect(entries[0]).toBe(entries[1]);
   });
 
-  afterAll(() => {
-    rmSync(testDir, { recursive: true });
+  afterAll(async () => {
+    await rm(testDir, { recursive: true });
   });
 });
diff --git a/packages/env/.madgerc b/packages/env/.madgerc
new file mode 100644
index 000000000..66f0c66c6
--- /dev/null
+++ b/packages/env/.madgerc
@@ -0,0 +1,7 @@
+{
+	"detectiveOptions": {
+		"ts": {
+			"skipTypeImports": true
+		}
+	}
+}
diff --git a/packages/env/jsr.json b/packages/env/jsr.json
index e9233e0d8..f2406b997 100644
--- a/packages/env/jsr.json
+++ b/packages/env/jsr.json
@@ -5,6 +5,6 @@
     ".": "./src/index.ts"
   },
   "publish": {
-    "include": ["LICENSE", "README.md", "src/**/*.ts", "jsr.json"]
+    "include": ["LICENSE", "README.md", "src/**/*", "jsr.json"]
   }
 }
diff --git a/packages/env/package.json b/packages/env/package.json
index 5bcaf7dce..5ceb7fa83 100644
--- a/packages/env/package.json
+++ b/packages/env/package.json
@@ -62,6 +62,7 @@
     "build:type": "tsc -p tsconfig.json",
     "postbuild": "node -e \"require('fs').writeFileSync('./dist/cjs/package.json', JSON.stringify({ type: 'commonjs' }))\"",
     "dev": "concurrently \"pnpm run build:esm --watch\" \"pnpm run build:cjs --watch\" \"pnpm run build:type --watch\"",
+    "circular-check": "madge -c ./src/index.ts",
     "test": "vitest"
   },
   "devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8dae7907e..f197b38fc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -40,6 +40,9 @@ importers:
       lint-staged:
         specifier: ^15.2.2
         version: 15.2.2
+      madge:
+        specifier: ^7.0.0
+        version: 7.0.0(typescript@5.4.5)
       prettier:
         specifier: ^3.2.5
         version: 3.2.5
@@ -461,9 +464,6 @@ importers:
       glob:
         specifier: ^10.3.12
         version: 10.3.12
-      madge:
-        specifier: ^7.0.0
-        version: 7.0.0(typescript@5.4.5)
       typescript:
         specifier: ^5.4.5
         version: 5.4.5
diff --git a/turbo.json b/turbo.json
index 85e908e14..f8ee85347 100644
--- a/turbo.json
+++ b/turbo.json
@@ -12,6 +12,7 @@
     "test": {
       "dependsOn": ["^build"]
     },
+    "circular-check": {},
     "e2e": {
       "dependsOn": ["^build"]
     },
-- 
GitLab