npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@asaidimu/node

v2.0.0

Published

A General Purpose Node Manager

Readme

@asaidimu/node

npm version License: MIT Build Status

A robust TypeScript library for managing hierarchical data structures with advanced features including versioning, events, validation, and hierarchical mapping.


🚀 Quick Links


✨ Overview & Features

@asaidimu/node is a general-purpose Node Manager designed to simplify the creation, manipulation, and querying of complex tree-like data structures in TypeScript and JavaScript applications. It provides a powerful and flexible way to represent hierarchical relationships, making it ideal for scenarios ranging from in-memory file systems and document management to UI component trees and configuration management.

This library emphasizes type safety, performance, and extensibility, offering built-in mechanisms for change tracking, event notification, and custom validation. With its intelligent caching and optimized traversal algorithms, @asaidimu/node empowers developers to build sophisticated applications that require robust hierarchical data handling with ease.

Key Features

  • 🌳 Hierarchical Data Management: Create, add, retrieve, update, remove, and move nodes within a tree structure. Supports arbitrary nesting depths.
  • 🔄 Versioning: Each node tracks its version, enabling optimistic concurrency control for safe updates in multi-user or distributed environments.
  • 📢 Event System: Subscribe to specific node events (add, update, remove, move) or a general node event, with optional path filtering for granular change detection.
  • Custom Validation: Define your own validation rules for nodes (data and metadata) to maintain data integrity and enforce business logic.
  • 🗺️ Hierarchical Object Mapping: Convert your tree structure into a nested plain JavaScript object based on a metadata key, simplifying data access for rendering or external consumption.
  • 🔗 Symbolic Linking: Create "link" nodes that reference existing nodes, allowing for multiple paths to the same underlying data without duplication.
  • ⚡️ Efficient Traversals: Perform Depth-First Search (DFS) and Breadth-First Search (BFS) walks over the tree, with callback functions to process nodes at each step.
  • 💾 Serialization & Persistence: Export and import the entire node store, allowing for easy persistence and reconstruction of your hierarchical data.
  • 🔒 Full Type Safety: Written entirely in TypeScript, providing robust type definitions and generics (<T, D>) for your custom node data and metadata.
  • 🚀 Optimized Performance: Internal caching mechanisms (e.g., for object maps) and efficient index management ensure responsive operations even with large datasets.
  • 🧩 Flexible Options: Configure behavior like maximum tree depth and maximum children per node.
  • Force Update & Object Expansion: Automatically create intermediate paths or expand JavaScript objects into a node hierarchy during updates.

📦 Installation & Setup

Prerequisites

  • Node.js: Version 18 or higher.
  • Package Manager: npm, yarn, or bun.

Installation Steps

To add @asaidimu/node to your project, use your preferred package manager:

# Using npm
npm install @asaidimu/node

# Using yarn
yarn add @asaidimu/node

# Using bun
bun add @asaidimu/node

📚 Core Concepts

Node Structure

At the heart of @asaidimu/node is the Node interface, which defines the structure of each element in your hierarchical data.

// NodeId and NodePath are simply string aliases
export type NodeId = string;
export type NodePath = string;
export type DataRecord = object; // Base type for custom metadata

/**
 * Represents the metadata associated with a node.
 * This includes system-managed properties (id, path, version, timestamps)
 * and allows for custom properties (`D`).
 */
export type NodeMetadata<T, D extends DataRecord> = D & {
  readonly id: NodeId;            // Unique identifier for the node
  readonly ref?: NodeId;          // Optional: references another node's data (for linking)
  readonly path: NodePath;        // The full hierarchical path (e.g., "root/child/grandchild")
  readonly parentId: NodeId | null; // ID of the parent node, null for root nodes
  readonly version: number;       // Incremental version for optimistic concurrency
  readonly createdAt: Date;       // Timestamp when the node was created
  readonly updatedAt: Date;       // Timestamp when the node was last updated
  key?: T extends object ? keyof T : string; // Optional: key for hierarchical mapping
};

/**
 * The fundamental unit of data in the NodeManager.
 * It encapsulates custom data (`T`), metadata (`D`), and references to its children.
 */
export interface Node<T, D extends DataRecord = {}> {
  readonly data: T;                   // Your custom application data
  readonly metadata: NodeMetadata<T, D>; // System and custom metadata
  readonly children: ReadonlySet<NodeId>; // Set of IDs of direct child nodes
}

This design allows you to define flexible data (T) and metadata (D) types specific to your application's needs, while the library handles the underlying hierarchical logic and system-level properties.

Real-World Use Cases

The flexibility of @asaidimu/node makes it suitable for a wide array of applications requiring structured, hierarchical data management.

1. File System Management

Perfect for building in-memory file systems with support for file operations, permissions, and watching.

import { createNodeManager } from '@asaidimu/node';

// Define types for file data and metadata
interface FileData {
  type: 'file' | 'directory';
  content?: string;
  size: number;
  mimeType?: string;
}

interface FileMetadata {
  owner: string;
  group: string;
  permissions: number; // Unix-style permissions
  isHidden: boolean;
}

// Create file system manager instance with custom options
const fs = createNodeManager<FileData, FileMetadata>({
  validator: (context) => {
    // Custom validation: Ensure directories don't have content or mimeType
    if (context.value?.type === 'directory') {
      return !context.value.content && !context.value.mimeType;
    }
    // Ensure files have content and mimeType
    return !!context.value?.content && !!context.value?.mimeType;
  },
  maxDepth: 32,      // Prevent excessively deep directory nesting
  maxChildren: 1000, // Limit number of files/directories per parent
});

// Create directory structure
fs.add('home',
  { type: 'directory', size: 0 },
  { owner: 'root', group: 'root', permissions: 0o755, isHidden: false }
);

// Add user directory
fs.add('home/user',
  { type: 'directory', size: 0 },
  { owner: 'user', group: 'users', permissions: 0o700, isHidden: false }
);

// Add a file
fs.add('home/user/doc.txt',
  {
    type: 'file',
    content: 'Hello World',
    size: 11,
    mimeType: 'text/plain'
  },
  {
    owner: 'user',
    group: 'users',
    permissions: 0o644,
    isHidden: false
  }
);

// Watch for updates within the user directory
fs.subscribe({ event: 'update', path: 'home/user' }, (data) => {
  console.log(`[FS Event] File changed: ${data.node.metadata.path}`);
  // data.node.data could be null if it's an intermediate node in a force update
  if (data.node.data) {
    console.log(`New size: ${data.node.data.size} bytes`);
  }
});

// List directory contents helper
const listDir = (path: string) => {
  const children = fs.getChildren(path);
  return children.map(child => ({
    name: child.metadata.path.split('/').pop(),
    ...child.data,
    ...child.metadata
  }));
};

console.log('--- Listing home/user ---');
console.log(listDir('home/user'));

// Move files
fs.move('home/user/doc.txt', 'home/user/documents/doc.txt');

// Export for persistence (e.g., to localStorage)
const snapshot = fs.export();
// localStorage.setItem('fs-state', JSON.stringify(snapshot)); // Example persistence

// Import (e.g., on application start)
// const savedState = JSON.parse(localStorage.getItem('fs-state') || '{}');
// fs.import(savedState);

2. Document Management System

Ideal for managing hierarchical documentation with versioning and access control.

import { createNodeManager } from '@asaidimu/node';

// Define types for document content and metadata
interface DocContent {
  title: string;
  content: string;
  summary?: string;
}

interface DocMetadata {
  author: string;
  status: 'draft' | 'review' | 'published';
  reviewers: string[];
  tags: string[];
  version: string;
  key: string; // Unique key for mapping
}

// Create document manager with validation rules
const docs = createNodeManager<DocContent, DocMetadata>({
  validator: (context) => {
    // Ensure required fields are present
    if (!context.value?.title || !context.value?.content) {
      console.warn(`Validation failed: Missing title or content for path ${context.path}`);
      return false;
    }

    // Ensure unique titles within the same parent
    if (context.parent) {
      const siblings = docs.getChildren(context.parent.metadata.path);
      const isTitleTaken = siblings.some(sibling =>
        sibling.data?.title === context.value?.title && // Check only against nodes with data
        sibling.metadata.id !== context.metadata.id
      );
      if (isTitleTaken) {
        console.warn(`Validation failed: Title "${context.value.title}" already exists under parent ${context.parent.metadata.path}`);
        return false;
      }
    }
    return true;
  }
});

// Add documentation structure
docs.add('technical',
  {
    title: 'Technical Documentation',
    content: 'Root for technical documentation',
    summary: 'Technical documentation repository'
  },
  {
    author: 'admin',
    status: 'published',
    reviewers: [],
    tags: ['root'],
    version: '1.0.0',
    key: 'tech-docs'
  }
);

// Add API documentation
docs.add('technical/api',
  {
    title: 'API Reference',
    content: '# API Documentation\n\nThis section covers...',
    summary: 'Complete API reference'
  },
  {
    author: 'alice',
    status: 'review',
    reviewers: ['bob', 'charlie'],
    tags: ['api', 'reference'],
    version: '0.1.0',
    key: 'api-docs'
  }
);

// Track document reviews using the event system
const notifyReviewers = (reviewers: string[]) => {
  console.log(`[Doc Event] Notifying reviewers: ${reviewers.join(', ')}`);
  // In a real app, this would send emails, messages, etc.
};
docs.subscribe('update', (data) => {
  if (data.node.metadata.status === 'review' && data.node.metadata.reviewers.length > 0) {
    notifyReviewers(data.node.metadata.reviewers);
  }
});

// Update a document, which will trigger the event
docs.update('technical/api',
  {
    title: 'API Reference v2',
    content: '# API Documentation v2\n\nThis section covers...',
    summary: 'Updated API reference'
  },
  {
    metadata: {
      status: 'review',
      reviewers: ['diana'],
      version: '0.2.0',
      key: 'api-docs'
    }
  }
);

// Generate hierarchical documentation map using the 'key' metadata field
const docMap = docs.render<Record<string, any>>(); // Use render() for public access to map
console.log('\n--- Generated Document Map ---');
console.log(JSON.stringify(docMap, null, 2));
/* Expected Output (simplified):
{
  "tech-docs": {
    "title": "Technical Documentation",
    "content": "Root for technical documentation",
    "summary": "Technical documentation repository",
    "api-docs": {
      "title": "API Reference v2",
      "content": "# API Documentation v2\n\nThis section covers...",
      "summary": "Updated API reference"
    }
  }
}
*/

3. Configuration Management

Managing hierarchical application configuration with inheritance and overrides.

import { createNodeManager } from '@asaidimu/node';

// Define types for configuration values and metadata
interface ConfigValue {
  value: any;
  description: string;
  type: 'string' | 'number' | 'boolean' | 'object' | 'array';
}

interface ConfigMetadata {
  scope: 'system' | 'user' | 'project';
  encrypted: boolean;
  lastModified: Date;
  key: string; // Unique key for mapping
}

const config = createNodeManager<ConfigValue, ConfigMetadata>({
  validator: (context) => {
    // Type validation
    const { value, type } = context.value;
    switch (type) {
      case 'string': return typeof value === 'string';
      case 'number': return typeof value === 'number';
      case 'boolean': return typeof value === 'boolean';
      case 'object': return typeof value === 'object' && value !== null && !Array.isArray(value);
      case 'array': return Array.isArray(value);
      default: return false;
    }
  }
});

// Add system configuration
config.add('system',
  {
    value: {},
    description: 'System configuration root',
    type: 'object'
  },
  {
    scope: 'system',
    encrypted: false,
    lastModified: new Date(),
    key: 'system'
  }
);

// Add database configuration
config.add('system/database',
  {
    value: {
      host: 'localhost',
      port: 5432,
      maxConnections: 100
    },
    description: 'Database configuration',
    type: 'object'
  },
  {
    scope: 'system',
    encrypted: true, // Mark as encrypted
    lastModified: new Date(),
    key: 'db'
  }
);

// Helper class to manage configuration access with encryption/decryption logic
class ConfigManager {
  constructor(private manager: typeof config) {}

  get<T = any>(path: string): T | null {
    const node = this.manager.get(path);
    if (!node || node.data === null) return null; // Handle null data for intermediate nodes

    let value = node.data.value;
    // Simulate decryption if marked
    if (node.metadata.encrypted) {
      value = this.decrypt(value);
    }
    return value;
  }

  set(path: string, value: any, type: ConfigValue['type'], scope: ConfigMetadata['scope'] = 'user', encrypted = false) {
    const existingNode = this.manager.get(path);
    let dataValue = value;

    // Simulate encryption if marked
    if (encrypted) {
      dataValue = this.encrypt(value);
    }

    const metadata: Partial<ConfigMetadata> = {
      scope,
      encrypted,
      lastModified: new Date(),
      key: existingNode?.metadata.key || path.split('/').pop() || '',
    };

    // Use force: true to create parent nodes if path doesn't exist
    return this.manager.update(path, {
      value: dataValue,
      type,
      description: existingNode?.data?.description || `Configuration for ${path}`,
    }, { force: true, metadata });
  }

  private encrypt(value: any): string {
    // Placeholder for actual encryption logic
    return `ENCRYPTED(${JSON.stringify(value)})`;
  }

  private decrypt(value: string): any {
    // Placeholder for actual decryption logic
    if (value.startsWith('ENCRYPTED(') && value.endsWith(')')) {
      return JSON.parse(value.substring('ENCRYPTED('.length, value.length - 1));
    }
    return value;
  }
}

// Usage
const configManager = new ConfigManager(config);

// Get decrypted database configuration
const dbConfig = configManager.get('system/database');
console.log('\n--- Database Configuration ---');
console.log(dbConfig); // Output: { host: 'localhost', port: 5432, maxConnections: 100 }

// Set a new user-specific configuration, automatically creating path
configManager.set('user/preferences/theme', 'dark', 'string', 'user');
configManager.set('user/preferences/notifications', true, 'boolean', 'user');

// Verify new config
console.log('\n--- User Preferences ---');
console.log('Theme:', configManager.get('user/preferences/theme'));
console.log('Notifications:', configManager.get('user/preferences/notifications'));

4. UI Component Tree

Managing component hierarchies with state and events for dynamic UI applications.

import { createNodeManager } from '@asaidimu/node';

// Define types for component data and metadata
interface ComponentData {
  type: string; // e.g., 'div', 'button', 'MyCustomComponent'
  props: Record<string, any>;
  state: Record<string, any>;
}

interface ComponentMetadata {
  isVisible: boolean;
  key: string; // Unique key for mapping
  renderCount: number;
  lastRendered: Date;
}

const ui = createNodeManager<ComponentData, ComponentMetadata>();

// Create a component tree
ui.add('app',
  {
    type: 'div',
    props: { className: 'app-container' },
    state: {}
  },
  {
    isVisible: true,
    key: 'app',
    renderCount: 0,
    lastRendered: new Date()
  }
);

ui.add('app/header',
  {
    type: 'header',
    props: { style: { background: '#f0f0f0', padding: '10px' } },
    state: { menuOpen: false }
  },
  {
    isVisible: true,
    key: 'header',
    renderCount: 0,
    lastRendered: new Date()
  }
);

ui.add('app/header/title',
  {
    type: 'h1',
    props: { children: 'My Awesome App' },
    state: {}
  },
  {
    isVisible: true,
    key: 'title',
    renderCount: 0,
    lastRendered: new Date()
  }
);

// Track component renders using events and update metadata
ui.subscribe('update', (data) => {
  // We only want to update renderCount for actual component nodes
  if (data.node.data) {
    ui.update(data.node.metadata.path, data.node.data, {
      metadata: {
        ...data.node.metadata,
        renderCount: (data.node.metadata.renderCount || 0) + 1,
        lastRendered: new Date()
      },
      // Ensure we don't accidentally expand data objects here
      expandObject: false
    });
  }
});

// Helper class to manage UI components
class UIManager {
  constructor(private manager: typeof ui) {}

  setState(path: string, state: Partial<Record<string, any>>) {
    const node = this.manager.get(path);
    if (!node || node.data === null) return;

    return this.manager.update(path, {
      ...node.data,
      state: { ...node.data.state, ...state }
    });
  }

  getState(path: string): Record<string, any> | null {
    return this.manager.get(path)?.data?.state || null;
  }

  // Renders the entire UI tree into a simplified HTML string
  render(): string {
    const tree = this.manager.render<Record<string, any>>(); // Get the full object map
    return this.renderNode(tree.app); // Start rendering from the 'app' root
  }

  private renderNode(nodeMap: any): string {
    // If it's a leaf node representing data (e.g., text content)
    if (nodeMap === null || typeof nodeMap !== 'object' || !nodeMap.type) {
        return nodeMap?.children || ''; // Handle potential string children or empty
    }

    const { type, props, state, ...childrenMap } = nodeMap;
    const childHtml = Object.entries(childrenMap)
      .filter(([key]) => typeof nodeMap[key] === 'object' && nodeMap[key] !== null) // Filter out direct properties like 'type', 'props', 'state'
      .map(([_, child]) => this.renderNode(child))
      .join('');

    const allProps = { ...props, 'data-state': JSON.stringify(state) };
    return `<${type} ${this.propsToString(allProps)}>${childHtml}</${type}>`;
  }

  private propsToString(props: Record<string, any>): string {
    return Object.entries(props)
      .map(([key, value]) => {
        if (typeof value === 'object' && value !== null) {
          value = JSON.stringify(value); // Stringify complex props like style objects
        }
        return `${key.replace(/[A-Z]/g, '-$&').toLowerCase()}="${value}"`; // Convert camelCase to kebab-case for HTML attributes
      })
      .join(' ');
  }
}

// Usage
const uiManager = new UIManager(ui);

// Update header state
uiManager.setState('app/header', { menuOpen: true });

// Log the current state of the header
console.log('\n--- Header State ---');
console.log('Header menu open:', uiManager.getState('app/header')?.menuOpen);

// Render the UI tree to HTML
console.log('\n--- Rendered UI ---');
console.log(uiManager.render());

/* Expected Output (simplified):
<div class="app-container" data-state="{}">
  <header style="{"background":"#f0f0f0","padding":"10px"}" data-state="{"menuOpen":true}">
    <h1 children="My Awesome App" data-state="{}"></h1>
  </header>
</div>
*/

📖 API Reference

The core functionality of @asaidimu/node is exposed through the NodeManager class.

createNodeManager<T, D>(options?: NodeManagerOptions<T, D>): NodeManager<T, D>

The factory function to create a new NodeManager instance.

  • T: Type for the node's custom data.
  • D: Type for the node's custom metadata (extends object).

NodeManagerOptions<T, D> Interface:

export interface NodeManagerOptions<T, D extends DataRecord> {
  readonly validator?: (context: ValidatorContext<T | null, D>) => boolean;
  readonly decorator?: (node: Node<T | null, D>) => Node<T | null, D>;
  readonly maxDepth?: number;
  readonly maxChildren?: number;
}

export interface ValidatorContext<T, D extends DataRecord> {
  readonly path: NodePath;
  readonly value: T;
  readonly metadata: D;
  readonly parent?: Node<T, D> | null;
}
  • validator: An optional function that receives a ValidatorContext and should return true if the node is valid, false otherwise. Throws ValidationError if validation fails.
  • decorator: An optional function to modify a node after it's created but before it's stored. Useful for adding computed properties or ensuring defaults.
  • maxDepth: The maximum allowed depth of any node path (default: 100). Throws ValidationError if exceeded.
  • maxChildren: The maximum number of direct children a parent node can have (default: 1000). Throws ValidationError if exceeded.

NodeManager<T, D> Core Methods

get(path: NodePath): Node<T | null, D> | null

Retrieves a node by its full hierarchical path.

  • path: The path of the node to retrieve (e.g., 'root/child').
  • Returns: The Node object if found, otherwise null.
const node = manager.get('home/user/doc.txt');
if (node) {
  console.log('Node data:', node.data);
}

add(path: NodePath, value: T, metadata: D = {} as D): Node<T | null, D>

Adds a new node to the tree. If parent nodes in the path do not exist, they must be created first.

  • path: The full path where the node should be added.
  • value: The custom data for the node.
  • metadata: Optional custom metadata for the node.
  • Returns: The newly created Node.
  • Throws: PathError if the path already exists, ValidationError if validation fails or maxChildren is exceeded.
manager.add('products/electronics/laptops', { name: 'Laptop A', price: 1200 });
manager.add('products/electronics/phones', { name: 'Phone X', price: 800 }, { brand: 'Acme' });

link(from: NodePath, to: NodePath): Node<T | null, D>

Creates a "link" node at to that references the data of the node at from. This allows for multiple logical paths to the same underlying data, useful for aliases or shared resources. Intermediate nodes in the to path will be created with null data.

  • from: The path of the existing node whose data should be referenced.
  • to: The new path where the link node will be created.
  • Returns: The newly created link Node.
  • Throws: PathError if from node is not found or to path already exists.
manager.add('original/data', { content: 'Shared content' });
manager.link('original/data', 'alias/for/data');

const originalNode = manager.get('original/data');
const linkNode = manager.get('alias/for/data');

console.log(originalNode?.data); // { content: 'Shared content' }
console.log(linkNode?.data);     // { content: 'Shared content' }

// Updating the original node will reflect in the link node
manager.update('original/data', { content: 'Updated shared content' });
console.log(linkNode?.data);     // { content: 'Updated shared content' }

update(path: NodePath, value: T, options?: UpdateOptions): Node<T | null, D>

Updates an existing node's data and/or metadata.

UpdateOptions Interface:

interface UpdateOptions {
  expectedVersion?: number; // For optimistic concurrency control
  metadata?: D;             // Partial metadata to merge
  force?: boolean;          // If true, creates intermediate nodes if path doesn't exist
  expandObject?: boolean;   // If true (and value is a plain object), creates child nodes for object properties
}
  • path: The path of the node to update.
  • value: The new data for the node.
  • options:
    • expectedVersion: If provided, the update will only proceed if the node's current version matches. Throws ConcurrencyError otherwise.
    • metadata: An object containing metadata properties to merge with the existing metadata.
    • force: If true, intermediate nodes in the path will be created if they don't exist. The final node will be created/updated.
    • expandObject: If true and value is a plain JavaScript object, its properties will be recursively created as child nodes. Primitives and arrays will be stored directly.
  • Returns: The updated Node.
  • Throws: PathError if the node is not found (and force is false), ConcurrencyError on version mismatch, ValidationError if validation fails.
// Initial node
manager.add('config/app', { port: 3000, debug: false }, { environment: 'development' });
const initialNode = manager.get('config/app')!;

// Update data and metadata with version check
manager.update(
  'config/app',
  { port: 8080, debug: true },
  {
    expectedVersion: initialNode.metadata.version,
    metadata: { environment: 'production' }
  }
);

// Force update a non-existent path, creating intermediate nodes
manager.update('logs/server/errors', { lastError: 'DB connection failed' }, { force: true });
console.log(manager.get('logs')?.data); // null (intermediate node)

// Update with object expansion
manager.update(
  'settings',
  { user: { name: 'Alice', email: '[email protected]' }, language: 'en' },
  { force: true, expandObject: true }
);
console.log(manager.get('settings/user/name')?.data); // Alice

remove(path: NodePath): boolean

Removes a node and all its descendants (subtree) from the tree.

  • path: The path of the node to remove.
  • Returns: true if the node was found and removed, false otherwise.
manager.add('documents/report/summary', {});
manager.remove('documents/report'); // Removes 'report' and 'summary'
console.log(manager.exists('documents/report/summary')); // false

move(fromPath: NodePath, toPath: NodePath, options?: MoveOptions): Node<T | null, D>

Moves a node (and its entire subtree) from one path to another. All descendant paths are updated accordingly.

MoveOptions Interface:

interface MoveOptions {
  expectedVersion?: number; // For optimistic concurrency control on the source node
}
  • fromPath: The current path of the node to move.
  • toPath: The new destination path for the node.
  • options:
    • expectedVersion: Optional version check for the fromPath node.
  • Returns: The moved Node (with its updated metadata).
  • Throws: PathError if fromPath does not exist or toPath already exists, ConcurrencyError on version mismatch, ValidationError if maxChildren is exceeded in the new parent.
manager.add('source/item1', { content: 'Item 1' });
manager.add('source/item1/subitem', { content: 'Sub Item' });
manager.add('destination', {});

manager.move('source/item1', 'destination/moved-item');

console.log(manager.get('destination/moved-item')?.data);       // { content: 'Item 1' }
console.log(manager.get('destination/moved-item/subitem')?.data); // { content: 'Sub Item' }
console.log(manager.exists('source/item1'));                    // false

getChildren(path: NodePath): Array<Node<T | null, D>>

Retrieves all direct children of a node specified by its path.

  • path: The path of the parent node.
  • Returns: An array of Node objects representing the direct children. Returns an empty array if the path doesn't exist or has no children.
manager.add('parent/child1', {});
manager.add('parent/child2', {});
const children = manager.getChildren('parent');
console.log(children.map(c => c.metadata.path)); // ['parent/child1', 'parent/child2']

exists(path: NodePath): boolean

Checks if a node exists at the given path.

  • path: The path to check.
  • Returns: true if a node exists, false otherwise.
manager.add('status/ready', {});
console.log(manager.exists('status/ready'));   // true
console.log(manager.exists('status/pending')); // false

subscribe(event: NodeEventType | { event: NodeEventType; path: string | string[] }, callback: (data: NodeEventData<T | null, D>) => void): () => void

Subscribes to node events.

NodeEventType:

  • 'add': Triggered when a node is added.

  • 'update': Triggered when a node's data or metadata is updated.

  • 'remove': Triggered when a node is removed (only for the removed node, not its subtree).

  • 'move': Triggered when a node is moved.

  • 'node': A wildcard event, triggered for any of the above.

  • event: A string representing the event type, or an object { event: NodeEventType; path: string | string[] } to subscribe only to events on specific paths.

  • callback: The function to call when the event is triggered. It receives NodeEventData.

  • Returns: An unsubscribe function that, when called, will remove the subscription.

// Subscribe to all 'add' events
const unsubscribeAdd = manager.subscribe('add', (data) => {
  console.log(`Node added: ${data.node.metadata.path}`);
});

// Subscribe to 'update' events on a specific path
const unsubscribeConfigUpdate = manager.subscribe(
  { event: 'update', path: 'config/settings' },
  (data) => {
    console.log(`Settings updated: ${data.node.metadata.path} to`, data.node.data);
  }
);

// Subscribe to all node events on multiple paths
const unsubscribeMultiPath = manager.subscribe(
  { event: 'node', path: ['users/alice', 'users/bob'] },
  (data) => {
    console.log(`Event on ${data.node.metadata.path}: ${data.type}`);
  }
);

manager.add('config/settings', { theme: 'dark' });
manager.update('config/settings', { theme: 'light' });
manager.add('users/alice', { name: 'Alice' });
manager.update('users/bob', { name: 'Bobby' }, { force: true }); // Bob is created then updated, only "add" triggers for "users/bob" initially
manager.move('users/alice', 'active-users/alice');

unsubscribeAdd();
unsubscribeConfigUpdate();
unsubscribeMultiPath();

store(): Readonly<NodeStore<T | null, D>>

Provides read-only access to the underlying NodeStore instance. This allows direct access to the NodeStore's methods like buildObject(), get(id), find(), serialize().

  • Returns: A read-only instance of NodeStore.
const store = manager.store();
// You can use store.get(nodeId) or store.find('key', value)

export(): Readonly<NodeStoreData<T | null, D>>

Exports the entire state of the node manager as a serializable object.

  • Returns: A NodeStoreData object containing nodes and pathIndex.
const exportedData = manager.export();
// Save `exportedData` to a file or database

import(data: NodeStoreData<T | null, D>): void

Imports a previously exported state, replacing the current state of the node manager.

  • data: The NodeStoreData object to import.
const savedState = { /* ... previously exported data ... */ };
manager.import(savedState);
console.log('Manager state restored.');

render<Q = Record<string, any>>(): Q

Builds a hierarchical object map from the root nodes, using metadata.key (or the default 'key') to define object keys. This is the public API for the powerful hierarchical mapping feature.

  • keyName: The name of the metadata field to use as keys in the resulting object map (defaults to 'key').
  • Returns: A hierarchical plain object representing the tree structure. The type Q can be used to type the output object.
// Assume nodes with metadata.key = 'home', 'user', 'doc.txt'
// E.g., manager.add('home/user/doc.txt', { content: 'Hello' }, { key: 'doc-file' });
const docMap = manager.render<Record<string, any>>();
/* Output might look like:
{
  home: {
    user: {
      'doc-file': { content: 'Hello' }
    }
  }
}
*/

dfsWalk(root: NodePath = "", walk: DfsWalker<T, D>): void

Performs a Depth-First Search (DFS) traversal of the tree, starting from a specified root path.

  • root: The path of the node to start the traversal from (defaults to the effective root "").
  • walker: A callback function DfsWalker executed for each visited node. Return false from the walker to halt the traversal.

DfsWalker<T, D> Interface:

export type DfsWalker<T, D extends DataRecord> = (opts: {
  readonly children: Array<Node<T | null, D>>; // Direct children of the current node
  readonly visited: Set<NodeId>;             // Set of all node IDs visited so far in this walk
  readonly node: Node<T | null, D>;         // The current node being visited
  readonly depth: number;                   // Current depth of the node in the traversal
  readonly store: NodeStore<T, D>;          // Readonly access to the underlying store
}) => boolean; // Return true to continue, false to stop traversal
manager.update("books/fiction/sci-fi/dune", "Dune content", { force: true });
manager.update("books/fiction/fantasy/lotr", "Lord of the Rings content", { force: true });

const visitedPaths: string[] = [];
manager.dfsWalk("books", ({ node, depth }) => {
  visitedPaths.push(`${"-".repeat(depth)}${node.metadata.path}`);
  // if (node.metadata.path === 'books/fiction/sci-fi') return false; // Example to stop early
  return true;
});
console.log('\n--- DFS Walk (books) ---');
console.log(visitedPaths.join('\n'));
/* Expected Output:
books
-books/fiction
--books/fiction/fantasy
---books/fiction/fantasy/lotr
--books/fiction/sci-fi
---books/fiction/sci-fi/dune
*/

Example: Detecting Cycles with DFS Walk (Advanced)

import { createNodeManager, PathError } from '@asaidimu/node';

const cycleManager = createNodeManager();
cycleManager.add("main-node", "Main");
cycleManager.add("main-node/sub-node", "Sub");
// Create a link from sub-node back to main-node, forming a cycle
cycleManager.link("main-node", "main-node/sub-node/back-to-main");

let cycleFound = false;
let cyclePath: string[] = [];
let ancestorPath: string | null = null;
let cycleDescendantId: string | null = null;
const pathTracker: [string, string][] = []; // Tracks [NodeId, NodePath] for current path in DFS stack

console.log('\n--- Cycle Detection using DFS ---');
cycleManager.dfsWalk("main-node", ({ node, visited, store, children }) => {
  // Push current node to path tracker
  pathTracker.push([node.metadata.id, node.metadata.path]);

  // Check children for references to already visited nodes in the current path
  for (const child of children) {
    const targetId = child.metadata.ref || child.metadata.id;

    // A cycle is detected if a child refers to an ancestor (already visited in current stack)
    if (visited.has(targetId)) {
      const ancestor = store.get(targetId);
      if (ancestor) {
        cycleFound = true;
        cycleDescendantId = node.metadata.id; // The node whose child creates the cycle
        ancestorPath = ancestor.metadata.path;

        // Reconstruct the path from the root of the walk to the descendant that formed the cycle
        // This involves filtering pathTracker up to cycleDescendantId
        let currentStackPathIds: string[] = [];
        for (const [id] of pathTracker) {
          currentStackPathIds.push(id);
          if (id === cycleDescendantId) break;
        }

        cyclePath = pathTracker
          .filter(([id]) => currentStackPathIds.includes(id))
          .map(([, path]) => path);

        return false; // Stop the traversal
      }
    }
  }

  // After processing children, pop the current node from pathTracker (conceptual)
  // In a true DFS, this happens implicitly by recursion, but here we manage it manually.
  // For the purpose of finding the *first* cycle, we don't need to pop.
  // If we wanted *all* cycles, we'd need a more complex state management for `pathTracker`.
  return true;
});

if (cycleFound && cycleDescendantId && ancestorPath) {
  const name = (p: string) => p.split('/').pop() || '';
  const finalPath = cyclePath.map(name).join(" -> ") + ` -> ${name(ancestorPath)}`;
  console.log(`✅ Cycle Detected: ${finalPath}`);
} else {
  console.log("No cycle detected.");
}

bfsWalk(root: NodePath = "", walk: BfsWalker<T, D>): void

Performs a Breadth-First Search (BFS) traversal of the tree, starting from a specified root path.

  • root: The path of the node to start the traversal from (defaults to the effective root "").
  • walker: A callback function BfsWalker executed for each visited node. Return false from the walker to halt the traversal.

BfsWalker<T, D> Interface:

export type BfsWalker<T, D extends DataRecord> = (opts: {
  readonly children: Array<Node<T | null, D>>; // Direct children of the current node
  readonly visited: Set<NodeId>;             // Set of all node IDs visited so far in this walk
  readonly node: Node<T | null, D>;         // The current node being visited
  readonly depth: number;                   // Current depth of the node in the traversal
  readonly store: NodeStore<T, D>;          // Readonly access to the underlying store
}) => boolean; // Return true to continue, false to stop traversal
manager.add("planets/earth/countries/usa", {});
manager.add("planets/mars/rovers/curiosity", {});

const bfsOrder: string[] = [];
manager.bfsWalk("planets", ({ node, depth }) => {
  bfsOrder.push(`${"-".repeat(depth)}${node.metadata.path}`);
  return true;
});
console.log('\n--- BFS Walk (planets) ---');
console.log(bfsOrder.join('\n'));
/* Expected Output:
planets
-planets/earth
-planets/mars
--planets/earth/countries
--planets/mars/rovers
---planets/earth/countries/usa
---planets/mars/rovers/curiosity
*/

Error Types

@asaidimu/node uses specific error classes to provide context about what went wrong. All custom errors extend NodeError.

export class NodeError extends Error {
  constructor(message: string, public readonly code: string, public readonly details?: unknown) { /* ... */ }
}

export class ValidationError extends NodeError {
  constructor(message: string, details?: unknown) { super(message, "VALIDATION_ERROR", details); }
}

export class PathError extends NodeError {
  constructor(message: string, details?: unknown) { super(message, "PATH_ERROR", details); }
}

export class ConcurrencyError extends NodeError {
  constructor(message: string, details?: unknown) { super(message, "CONCURRENCY_ERROR", details); }
}
  • NodeError: Base class for all library-specific errors.
  • ValidationError: Thrown when a node fails a custom validator check, maxDepth, or maxChildren limits.
  • PathError: Thrown for issues related to node paths, such as a path not existing when expected, or a destination path already being occupied.
  • ConcurrencyError: Thrown during update or move operations if an expectedVersion does not match the node's current version, indicating a concurrent modification.

💻 Development & Contributing

We welcome contributions! If you're interested in improving @asaidimu/node, please follow these guidelines.

Development Setup

  1. Clone the repository:
    git clone https://github.com/asaidimu/node.git
    cd node
  2. Install dependencies: This project uses bun for package management.
    bun install

Available Scripts

The package.json defines several scripts for development and building:

  • bun ci: Installs dependencies. Used in CI environments but also locally to ensure fresh install.
  • bun test: Runs all unit tests using vitest.
  • bun test:ci: Runs all unit tests once, suitable for CI environments.
  • bun clean: Removes the dist directory.
  • bun prebuild: Cleans the dist directory and synchronizes dist.package.json.
  • bun build: Compiles the TypeScript source files to JavaScript (CJS and ESM formats) and generates declaration files (.d.ts) using tsup.
  • bun postbuild: Copies README.md, LICENSE.md, and dist.package.json into the dist directory for proper package publishing.

Testing

Tests are written using vitest. To run them:

bun test
# For continuous testing (watch mode)
bun test --watch

Ensure all new features or bug fixes are accompanied by appropriate unit tests.

Contributing Guidelines

  • Fork the repository and create your branch from main.
  • Write clear, concise code that adheres to the existing style.
  • Add/update tests for any new or changed functionality.
  • Update documentation as needed, especially the API reference.
  • Ensure all tests pass before submitting a pull request.
  • Commit messages should follow the Conventional Commits specification.

Issue Reporting

If you find a bug or have a feature request, please open an issue on GitHub: https://github.com/asaidimu/node/issues


ℹ️ Additional Information

Troubleshooting

  • PathError: Double-check your node paths for correct formatting ('parent/child') and ensure the node actually exists at the specified path for operations like get, update, remove, move.
  • ValidationError: Review your NodeManager options, especially validator, maxDepth, and maxChildren. The error details will provide context about which rule was violated.
  • ConcurrencyError: This typically means another process or user modified the node between when you read its version and when you tried to update it with an expectedVersion. Implement retry logic or a merge strategy in your application to handle this.
  • Node data is null after force: true update: When update with force: true creates intermediate nodes (e.g., a and a/b for path a/b/c), these intermediate nodes often have null data by default, unless explicitly provided. The data will only be set on the final node in the path.

Changelog / Roadmap

  • Changelog: For a detailed history of changes and releases, please refer to the CHANGELOG.md.
  • Roadmap: Future enhancements may include more advanced query capabilities, pluggable storage adapters, and performance optimizations for extremely large trees.

License

This project is licensed under the MIT License. See the LICENSE.md file for details.

Acknowledgments

Developed and maintained by Saidimu.