@asaidimu/node
v2.0.0
Published
A General Purpose Node Manager
Readme
@asaidimu/node
A robust TypeScript library for managing hierarchical data structures with advanced features including versioning, events, validation, and hierarchical mapping.
🚀 Quick Links
- Overview & Features
- Installation & Setup
- Core Concepts
- API Reference
- Development & Contributing
- Additional Information
✨ 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 generalnodeevent, 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, orbun.
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 (extendsobject).
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 aValidatorContextand should returntrueif the node is valid,falseotherwise. ThrowsValidationErrorif 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). ThrowsValidationErrorif exceeded.maxChildren: The maximum number of direct children a parent node can have (default: 1000). ThrowsValidationErrorif 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
Nodeobject if found, otherwisenull.
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:
PathErrorif the path already exists,ValidationErrorif validation fails ormaxChildrenis 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:
PathErroriffromnode is not found ortopath 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. ThrowsConcurrencyErrorotherwise.metadata: An object containing metadata properties to merge with the existing metadata.force: Iftrue, intermediate nodes in the path will be created if they don't exist. The final node will be created/updated.expandObject: Iftrueandvalueis 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:
PathErrorif the node is not found (andforceisfalse),ConcurrencyErroron version mismatch,ValidationErrorif 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); // Aliceremove(path: NodePath): boolean
Removes a node and all its descendants (subtree) from the tree.
path: The path of the node to remove.- Returns:
trueif the node was found and removed,falseotherwise.
manager.add('documents/report/summary', {});
manager.remove('documents/report'); // Removes 'report' and 'summary'
console.log(manager.exists('documents/report/summary')); // falsemove(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 thefromPathnode.
- Returns: The moved
Node(with its updated metadata). - Throws:
PathErroriffromPathdoes not exist ortoPathalready exists,ConcurrencyErroron version mismatch,ValidationErrorifmaxChildrenis 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')); // falsegetChildren(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
Nodeobjects 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:
trueif a node exists,falseotherwise.
manager.add('status/ready', {});
console.log(manager.exists('status/ready')); // true
console.log(manager.exists('status/pending')); // falsesubscribe(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 receivesNodeEventData.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
NodeStoreDataobject containingnodesandpathIndex.
const exportedData = manager.export();
// Save `exportedData` to a file or databaseimport(data: NodeStoreData<T | null, D>): void
Imports a previously exported state, replacing the current state of the node manager.
data: TheNodeStoreDataobject 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
Qcan 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 functionDfsWalkerexecuted for each visited node. Returnfalsefrom 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 traversalmanager.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 functionBfsWalkerexecuted for each visited node. Returnfalsefrom 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 traversalmanager.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 customvalidatorcheck,maxDepth, ormaxChildrenlimits.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 duringupdateormoveoperations if anexpectedVersiondoes 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
- Clone the repository:
git clone https://github.com/asaidimu/node.git cd node - Install dependencies: This project uses
bunfor 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 usingvitest.bun test:ci: Runs all unit tests once, suitable for CI environments.bun clean: Removes thedistdirectory.bun prebuild: Cleans thedistdirectory and synchronizesdist.package.json.bun build: Compiles the TypeScript source files to JavaScript (CJS and ESM formats) and generates declaration files (.d.ts) usingtsup.bun postbuild: CopiesREADME.md,LICENSE.md, anddist.package.jsoninto thedistdirectory for proper package publishing.
Testing
Tests are written using vitest. To run them:
bun test
# For continuous testing (watch mode)
bun test --watchEnsure 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 likeget,update,remove,move.ValidationError: Review yourNodeManageroptions, especiallyvalidator,maxDepth, andmaxChildren. 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 anexpectedVersion. Implement retry logic or a merge strategy in your application to handle this.- Node data is
nullafterforce: trueupdate: Whenupdatewithforce: truecreates intermediate nodes (e.g.,aanda/bfor patha/b/c), these intermediate nodes often havenulldataby default, unless explicitly provided. Thedatawill 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.
