gnss-state
Version:
Shared singleton state service for Angular MFE architecture
1,121 lines (1,120 loc) • 102 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
// Angular Imports
/**
* A comprehensive reactive state management service for Angular applications that provides
* centralized state storage, hierarchical organization, and robust backup/rollback functionality.
*
* This service implements a sophisticated state management pattern using RxJS BehaviorSubjects
* to enable reactive data flow throughout Angular applications. It supports both flat and
* hierarchical state organization through groups, automatic backup creation for rollback
* operations, and deep merging capabilities for complex state updates.
*
* ## Key Features
*
* - **Reactive State Management**: Built on RxJS BehaviorSubjects for reactive data flow
* - **Hierarchical Organization**: Support for grouped state subjects with nested structures
* - **Backup & Rollback**: Automatic backup creation with reset functionality for undo operations
* - **Deep Merging**: Intelligent object merging for partial state updates
* - **Type Safety**: Full TypeScript support with type guards and strict typing
* - **Memory Management**: Proper cleanup and resource management to prevent memory leaks
* - **Change Detection**: Optimized updates with deep equality checks to prevent unnecessary emissions
*
* ## Usage Patterns
*
* ### Basic State Operations
* ```typescript
* // Inject the service
* constructor(private stateModel: StateModel) {}
*
* // Set initial state
* this.stateModel.set('userProfile', { id: 1, name: 'John', email: 'john@example.com' });
*
* // Subscribe to state changes
* this.stateModel.observe('userProfile').subscribe(profile => {
* console.log('Profile updated:', profile);
* });
*
* // Update existing state
* this.stateModel.update('userProfile', { name: 'Jane Doe' });
*
* // Reset to backup value
* this.stateModel.reset('userProfile');
* ```
*
* ### Grouped State Management
* ```typescript
* // Create and populate a group
* const users = [
* { id: 'user1', name: 'John', role: 'admin' },
* { id: 'user2', name: 'Jane', role: 'user' }
* ];
* this.stateModel.loadGroup('users', 'id', users);
*
* // Access grouped subjects
* this.stateModel.observe('user1', 'users').subscribe(user => {
* console.log('User updated:', user);
* });
*
* // Update grouped state
* this.stateModel.set('currentUser', { id: 3, name: 'Bob' }, 'users');
* ```
*
* ### Form Integration with Backup/Reset
* ```typescript
* // Initialize form state
* this.stateModel.set('formData', this.form.value);
*
* // Track form changes
* this.form.valueChanges.subscribe(value => {
* this.stateModel.update('formData', value);
* });
*
* // Reset form to initial state
* onResetClick() {
* this.stateModel.reset('formData');
* this.form.patchValue(this.stateModel.value('formData'));
* }
* ```
*
* ## Architecture
*
* The service maintains several core registries:
* - **Main Directory**: Flat registry of named BehaviorSubjects
* - **Groups**: Hierarchical registry for organized state subjects
* - **Backups**: Rollback data for main directory subjects
* - **Group Backups**: Rollback data for grouped subjects
* - **Change Tracking**: Reactive notifications for state modifications
*
* ## Performance Optimizations
*
* - Deep equality checks prevent unnecessary BehaviorSubject emissions
* - Automatic cleanup prevents memory leaks
* - Smart merging preserves existing state properties
* - Lazy initialization of subjects and groups
*
* @example
* ## Complete Example: User Management with Groups
* ```typescript
* @Component({...})
* export class UserManagementComponent implements OnInit {
* constructor(private stateModel: StateModel) {}
*
* ngOnInit() {
* // Initialize user management state
* this.loadUsers();
* this.setupSubscriptions();
* }
*
* private loadUsers() {
* const users = [
* { id: 'user1', name: 'John Doe', email: 'john@example.com', active: true },
* { id: 'user2', name: 'Jane Smith', email: 'jane@example.com', active: false }
* ];
*
* // Create grouped state for users
* this.stateModel.loadGroup('users', 'id', users);
*
* // Set selected user
* this.stateModel.set('selectedUser', users[0], 'users');
* }
*
* private setupSubscriptions() {
* // React to selected user changes
* this.stateModel.observe('selectedUser', 'users').subscribe(user => {
* if (user) {
* this.loadUserDetails(user);
* }
* });
*
* // Monitor all group changes
* this.stateModel.groupList.subscribe(groups => {
* console.log('Available groups:', groups);
* });
* }
*
* updateUser(userId: string, updates: Partial<User>) {
* // Merge partial updates with existing user data
* this.stateModel.dirMerge(updates, userId, 'users');
* }
*
* resetUserChanges(userId: string) {
* // Reset user to backup state
* this.stateModel.reset(userId, 'users');
* }
* }
* ```
*
* @see {@link StateObjType} - Type definition for state values
* @see {@link BehaviorSubject} - RxJS reactive primitive used for state storage
*
* @public
* @injectable
* @since 1.0.0
*/
class StateModel {
// -------------------- SUBSCRIPTIONS -------------------- //
// -------------------- SUBSCRIBER_ATTRIBUTES -------------------- //
// -------------------- SUBSCRIBER_OBJECT_REFERENCES -------------------- //
// -------------------- ATTRIBUTES -------------------- //
// -------------------- OBJECTS -------------------- //
/**
* Registry of backup values for state subjects to enable rollback functionality.
*
* Stores the original values of state subjects when they are created or modified through the
* {@link StateModel.set} method. These backup values remain immutable during subsequent
* {@link StateModel.update} operations and can be restored using {@link StateModel.reset}.
* This mechanism supports undo/reset patterns commonly used in form controls and dialog
* components.
*
* @remarks
* The backup system follows these rules:
* - Backups are created/updated only by the `set()` method
* - The `update()` method does not modify backup values
* - The `reset()` method restores values from this backup registry
* - Backups are stored as deep clones to prevent reference mutations
*
* @example
* Basic backup and reset workflow:
* ```typescript
* // Setting creates backup automatically
* stateModel.set('userForm', { name: 'John', email: 'john@example.com' });
*
* // Backup contains original value
* console.log(stateModel.backups['userForm']);
* // Output: { name: 'John', email: 'john@example.com' }
*
* // Updates don't affect backup
* stateModel.update('userForm', { name: 'Jane', email: 'jane@example.com' });
*
* // Backup remains unchanged
* console.log(stateModel.backups['userForm']);
* // Output: { name: 'John', email: 'john@example.com' }
*
* // Reset restores from backup
* stateModel.reset('userForm');
* // Current value: { name: 'John', email: 'john@example.com' }
* ```
*
* @see {@link StateModel.set} - Creates and updates backup values
* @see {@link StateModel.update} - Modifies state without affecting backups
* @see {@link StateModel.reset} - Restores state from backup values
* @see {@link StateModel.groupBackups} - Backup registry for grouped subjects
*
* @since 1.0.0
*/
backups = {};
/**
* A registry of reactive state subjects that can be observed by any component
* throughout the application. This registry uses a key-value mapping pattern
* where each key represents a unique state identifier and each value is a
* BehaviorSubject containing the state data.
*
* This design pattern provides centralized state management with reactive
* capabilities, allowing components to subscribe to specific state changes
* without tight coupling to state producers.
*
* @example
* ```typescript
* // Subscribe to a specific state
* this.stateModel.dir['userProfile'].subscribe(profile => {
* console.log('User profile updated:', profile);
* });
*
* // Access current value
* const currentUser = this.stateModel.dir['userProfile'].value;
* ```
*
* @see {@link StateModel.observe} For safe access to subjects with automatic creation
* @see {@link StateModel.set} For creating and updating state subjects
* @see {@link StateModel.update} For modifying existing state subjects
*
* @public
* @since 1.0.0
*/
dir = {};
/**
* A reactive registry of all group names currently managed by the state system.
*
* This BehaviorSubject maintains an array of string identifiers representing
* all group names that exist in the {@link StateModel.groups} registry. It provides
* a reactive way to track when new groups are created or existing groups are removed,
* enabling components to respond to structural changes in the grouped state architecture.
*
* The group list is automatically maintained by the state management system:
* - Group names are added when {@link StateModel.loadGroup} creates new groups
* - Group names are removed when {@link StateModel.removeGroup} deletes groups
* - The list is kept in sync with the actual {@link StateModel.groups} registry
*
* @example
* Subscribe to group registry changes:
* ```typescript
* this.stateModel.groupList.subscribe(groupNames => {
* console.log('Available groups:', groupNames);
* // Output: ['users', 'settings', 'navigation']
* });
* ```
*
* @example
* Check if a specific group exists:
* ```typescript
* const hasUsersGroup = this.stateModel.groupList.value.includes('users');
* ```
*
* @see {@link StateModel.groups} - The actual group registry this list tracks
* @see {@link StateModel.loadGroup} - Method that adds group names to this list
* @see {@link StateModel.removeGroup} - Method that removes group names from this list
*
* @public
* @since 1.0.0
*/
groupList = new BehaviorSubject([]);
/**
* Hierarchical registry of backup values for grouped state subjects to enable rollback functionality.
*
* This two-tier backup system maintains original values for state subjects organized within groups,
* complementing the main {@link StateModel.backups} registry for ungrouped subjects. Each group
* maintains its own backup registry, where backup values remain immutable during subsequent
* {@link StateModel.update} operations and can be restored using {@link StateModel.reset}.
*
* The structure follows a group-name → subject-name → backup-value hierarchy, allowing for
* fine-grained backup management within the grouped state architecture. This supports complex
* UI patterns where different feature areas require independent rollback capabilities.
*
* @remarks
* The hierarchical backup system follows these rules:
* - Group backups are created/updated only by the `set()` method when a group name is specified
* - The `update()` method does not modify backup values in any group
* - The `reset()` method can restore individual subjects within specific groups
* - Group removal via `removeGroup()` clears all backup data for that group
* - Backups are stored as deep clones to prevent reference mutations
*
* @example
* Grouped backup and reset workflow:
* ```typescript
* // Setting with group creates backup automatically
* stateModel.set('currentUser', { id: 1, name: 'John' }, 'users');
* stateModel.set('permissions', ['read', 'write'], 'users');
*
* // Group backup structure:
* // {
* // 'users': {
* // 'currentUser': { id: 1, name: 'John' },
* // 'permissions': ['read', 'write']
* // }
* // }
*
* // Updates don't affect group backups
* stateModel.update('currentUser', { id: 1, name: 'Jane' }, 'users');
*
* // Group backup remains unchanged
* console.log(stateModel.groupBackups['users']['currentUser']);
* // Output: { id: 1, name: 'John' }
*
* // Reset specific subject within group
* stateModel.reset('currentUser', 'users');
* // Current value restored: { id: 1, name: 'John' }
* ```
*
* @see {@link StateModel.backups} - Main backup registry for ungrouped subjects
* @see {@link StateModel.groups} - The grouped subjects this backup system supports
* @see {@link StateModel.set} - Creates and updates group backup values
* @see {@link StateModel.reset} - Restores state from group backup values
* @see {@link StateModel.removeGroup} - Clears all backups for a specific group
*
* @public
* @since 1.0.0
*/
groupBackups = {};
/**
* A Directory of grouped BehaviorSubjects where each group is itself a BehaviorSubject
* containing a Record of named BehaviorSubjects. This nested structure allows for
* reactive group management where the entire group can be observed for changes,
* and individual subjects within each group can also be observed independently.
*
* The outer Record maps group names to BehaviorSubjects, where each BehaviorSubject
* contains a Record mapping subject names to their respective BehaviorSubjects.
* This enables hierarchical state management with both group-level and item-level
* reactivity.
*
* @example
* ```typescript
* // Access a specific subject within a group
* const userSubject = this.groups['users'].value['john'];
*
* // Subscribe to changes in the entire group
* this.groups['users'].subscribe(groupRecord => {
* console.log('Group changed:', Object.keys(groupRecord));
* });
*
* // Subscribe to changes in a specific subject within the group
* this.groups['users'].value['john'].subscribe(userData => {
* console.log('User data changed:', userData);
* });
* ```
*/
groups = {};
/**
* A reactive tracker that emits the name of the most recently updated state subject.
*
* This BehaviorSubject provides a centralized way to monitor state change activity
* across the entire state management system. It emits the subject name whenever
* any state modification occurs through the {@link StateModel.set}, {@link StateModel.update},
* or {@link StateModel.initiate} methods, enabling reactive side effects and UI updates
* based on specific state changes.
*
* The tracker operates across both individual subjects in {@link StateModel.dir}
* and grouped subjects in {@link StateModel.groups}, providing unified change
* detection regardless of state organization.
*
* @example
* Monitor all state changes:
* ```typescript
* this.stateModel.updated.subscribe(subjectName => {
* if (subjectName) {
* console.log(`State subject '${subjectName}' was updated`);
* // Trigger side effects, analytics, or UI updates
* }
* });
* ```
*
* @example
* React to specific subject changes:
* ```typescript
* this.stateModel.updated.pipe(
* filter(name => name === 'userProfile'),
* switchMap(() => this.stateModel.observe('userProfile'))
* ).subscribe(profile => {
* // Handle user profile changes
* });
* ```
*
* @remarks
* - Emits `undefined` on initialization
* - Always emits the subject name, not the group name for grouped subjects
* - Useful for implementing global state change listeners and audit trails
* - Can be used to invalidate caches or trigger data synchronization
*
* @see {@link StateModel.set} - Method that triggers updates when setting state
* @see {@link StateModel.update} - Method that triggers updates when modifying state
* @see {@link StateModel.initiate} - Method that triggers updates when initializing state
*
* @public
* @since 1.0.0
*/
updated = new BehaviorSubject(undefined);
// -------------------- CONSTRUCTOR -------------------- //
// -------------------- LIFE_CYCLE_HOOKS -------------------- //
// -------------------- INITIATOR_LOADER_METHODS -------------------- //
// -------------------- PRIVATE_METHODS -------------------- //
/**
* Type guard that determines whether a value is a plain object suitable for deep merging operations.
*
* This utility method implements a type-safe check to identify plain objects created by object
* literals or the Object constructor, distinguishing them from arrays, dates, class instances,
* and other specialized object types. It uses the reliable `Object.prototype.toString.call()`
* approach to ensure accurate type detection across different JavaScript contexts and
* inheritance chains.
*
* Plain objects are defined as objects with `[object Object]` as their internal [[Class]] slot,
* which includes:
* - Object literals: `{ key: 'value' }`
* - Objects created with `new Object()`
* - Objects created with `Object.create(Object.prototype)`
*
* Non-plain objects that return `false` include:
* - Arrays: `[1, 2, 3]`
* - Dates: `new Date()`
* - RegExp: `/pattern/`
* - Functions: `() => {}`
* - Class instances: `new MyClass()`
* - Built-in objects: `Math`, `JSON`, etc.
* - Primitives: `null`, `undefined`, strings, numbers, booleans
*
* @param val - The value to test for plain object status. Accepts any type including
* primitives, objects, arrays, null, or undefined.
*
* @returns Type predicate indicating whether the value is a plain object. When `true`,
* TypeScript narrows the type to `Record<string, unknown>`, enabling safe property access
* and iteration. When `false`, the value is not suitable for deep merging operations.
*
* @example
* Basic plain object detection:
* ```typescript
* this.isPlainObject({}); // true
* this.isPlainObject({ name: 'John' }); // true
* this.isPlainObject(new Object()); // true
* this.isPlainObject([1, 2, 3]); // false
* this.isPlainObject(new Date()); // false
* this.isPlainObject(null); // false
* this.isPlainObject('string'); // false
* ```
*
* @example
* Usage in merge operations:
* ```typescript
* if (this.isPlainObject(sourceObj) && this.isPlainObject(targetObj)) {
* // Safe to perform recursive merge
* return this.merge(sourceObj, targetObj);
* } else {
* // Replace non-plain objects completely
* return sourceObj;
* }
* ```
*
* @example
* Type narrowing with type guard:
* ```typescript
* function processValue(input: unknown) {
* if (this.isPlainObject(input)) {
* // TypeScript knows 'input' is Record<string, unknown>
* console.log(Object.keys(input)); // No type error
* return input.someProperty; // Safe property access
* }
* // Handle non-plain objects differently
* return input;
* }
* ```
*
* @remarks
* - Uses `Object.prototype.toString.call()` for reliable cross-frame type detection
* - Serves as a TypeScript type guard for enhanced type safety in merge operations
* - Essential for preventing incorrect recursive merging of arrays, dates, and class instances
* - Performance is O(1) with minimal overhead for type checking
* - Does not check for object enumerability or property descriptors
*
* @see {@link StateModel.merge} - Primary consumer of this type guard for safe object merging
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/toString
*
* @internal
* @since 1.0.0
*/
isPlainObject(val) {
return Object.prototype.toString.call(val) === '[object Object]';
}
// -------------------- PUBLIC_METHODS -------------------- //
/**
* Retrieves the current values from all BehaviorSubjects in the state registry.
*
* Extracts and returns the current values from all registered state subjects,
* either from the main {@link StateModel.dir} registry or from a specific group
* within {@link StateModel.groups}. The returned object uses subject names as keys
* and their current values as the corresponding values, providing a snapshot
* of the current state at the time of invocation.
*
* Values are deep cloned through the {@link StateModel.value} method to prevent
* external mutations from affecting the state management system.
*
* @param groupName - Optional group name to limit extraction to subjects within
* a specific group. When `null` (default), retrieves values from all subjects
* in the main directory. When specified, only retrieves values from subjects
* within the named group.
*
* @returns A record object where keys are subject names and values are the
* current state values of the corresponding BehaviorSubjects. Returns an
* empty object if no subjects exist or if the specified group doesn't exist.
*
* @example
* Get all values from the main directory:
* ```typescript
* const allValues = this.stateModel.getDirValues();
* // Returns: { userProfile: {...}, settings: {...}, navigation: {...} }
* ```
*
* @example
* Get values from a specific group:
* ```typescript
* const userValues = this.stateModel.getDirValues('users');
* // Returns: { currentUser: {...}, permissions: [...] }
* ```
*
* @see {@link StateModel.dir} - Main state subject registry
* @see {@link StateModel.groups} - Grouped state subject registry
* @see {@link StateModel.value} - Method used internally to extract values
*
* @public
* @since 1.0.0
*/
getDirValues(groupName = null) {
const subjectDirectory = {};
if (!groupName) {
for (const key in this.dir) {
subjectDirectory[key] = this.value(key);
}
}
if (groupName && groupName in this.groups) {
for (const key in this.groups[groupName].value) {
subjectDirectory[key] = this.value(key, groupName);
}
}
return subjectDirectory;
}
/**
* Determines whether a BehaviorSubject with the specified name exists in the state registry.
*
* This method performs existence checks in either the main {@link StateModel.dir} registry
* or within a specific group in {@link StateModel.groups}. When a group name is provided,
* the method first verifies the group exists before checking for the subject within that group.
* This dual-level checking supports the hierarchical state management architecture.
*
* @param subjectName - The name of the BehaviorSubject to check for existence.
* This parameter is required and represents the unique identifier for the state subject.
* @param groupName - Optional group name to limit the search to subjects within
* a specific group. When `null` (default), searches in the main directory registry.
* When specified, searches only within the named group.
*
* @returns `true` if the subject exists in the specified location (main directory
* or within the specified group), `false` otherwise. Returns `false` if the
* specified group doesn't exist.
*
* @example
* Check for subject in main directory:
* ```typescript
* const hasUserProfile = this.stateModel.has('userProfile');
* // Returns true if 'userProfile' exists in main directory
* ```
*
* @example
* Check for subject within a specific group:
* ```typescript
* const hasCurrentUser = this.stateModel.has('currentUser', 'users');
* // Returns true if 'currentUser' exists within 'users' group
* ```
*
* @see {@link StateModel.dir} - Main state subject registry
* @see {@link StateModel.groups} - Grouped state subject registry
* @see {@link StateModel.observe} - Method for safely accessing subjects with automatic creation
*
* @public
* @since 1.0.0
*/
has(subjectName, groupName = null) {
if (!groupName) {
if (this.dir[subjectName]) {
return true;
}
}
if (groupName) {
if (this.groups[groupName] && this.groups[groupName].value[subjectName]) {
return true;
}
}
return false;
}
/**
* Initializes or resets a BehaviorSubject to an undefined state within the state management system.
*
* This method creates new BehaviorSubjects with an initial value of `undefined`, or resets existing
* subjects to `undefined` if they currently contain other values. It supports both standalone subjects
* in the main {@link StateModel.dir} registry and grouped subjects within {@link StateModel.groups}.
* When working with grouped subjects, the method will automatically create the parent group if it
* doesn't exist.
*
* The initialization process includes:
* - Creating new BehaviorSubjects with `undefined` as the initial value
* - Resetting existing subjects to `undefined` (no-op if already `undefined`)
* - Setting up corresponding backup entries for rollback functionality
* - Auto-creating group structures when specified
* - Triggering the {@link StateModel.updated} notification system
*
* @param subjectName - The unique identifier for the BehaviorSubject to initialize or reset.
* Must be a non-empty string that will serve as the key in the state registry.
* @param groupName - Optional group name to organize the subject within a hierarchical structure.
* When `null` (default), the subject is created in the main directory. When specified, the subject
* is created within the named group, and the group itself is created if it doesn't exist.
*
* @returns void - This method performs side effects but returns no value.
*
* @example
* Initialize a standalone subject:
* ```typescript
* // Creates a new BehaviorSubject with undefined value
* this.stateModel.initiate('userProfile');
*
* // Access the initialized subject
* this.stateModel.observe('userProfile').subscribe(value => {
* console.log(value); // undefined initially
* });
* ```
*
* @example
* Initialize a subject within a group:
* ```typescript
* // Creates both 'users' group and 'currentUser' subject if they don't exist
* this.stateModel.initiate('currentUser', 'users');
*
* // Reset an existing grouped subject to undefined
* this.stateModel.set('currentUser', { id: 1, name: 'John' }, 'users');
* this.stateModel.initiate('currentUser', 'users'); // Resets to undefined
* ```
*
* @example
* Resetting existing subjects:
* ```typescript
* // Subject with existing data
* this.stateModel.set('settings', { theme: 'dark', lang: 'en' });
*
* // Reset to undefined (triggers backup and notification)
* this.stateModel.initiate('settings');
*
* // No-op if already undefined
* this.stateModel.initiate('settings'); // No change, no notification
* ```
*
* @remarks
* - Early return optimization: If the target subject already has an `undefined` value, the method returns early without triggering notifications
* - Group auto-creation: When a group name is specified but the group doesn't exist, both the group and backup registry are automatically created
* - Backup management: All initiated subjects receive corresponding backup entries set to `undefined`
* - Notification system: The {@link StateModel.updated} BehaviorSubject is notified with the subject name after successful operations
*
* @see {@link StateModel.set} - For setting subjects with specific initial values
* @see {@link StateModel.observe} - For safely accessing and subscribing to subjects
* @see {@link StateModel.reset} - For restoring subjects from backup values
* @see {@link StateModel.loadGroup} - For bulk group creation with multiple subjects
*
* @public
* @since 1.0.0
*/
initiate(subjectName, groupName = null) {
if (subjectName) {
if (!groupName) {
if (!this.dir[subjectName]) {
this.dir[subjectName] = new BehaviorSubject(undefined);
this.backups[subjectName] = undefined;
}
else {
const currentValue = this.dir[subjectName].value;
if (currentValue === undefined)
return;
this.dir[subjectName].next(undefined);
this.backups[subjectName] = undefined;
}
}
if (groupName) {
if (!this.groups[groupName]) {
this.groups[groupName] = new BehaviorSubject({});
this.groupBackups[groupName] = {};
}
if (!this.groups[groupName].value[subjectName]) {
this.groups[groupName].value[subjectName] =
new BehaviorSubject(undefined);
this.groupBackups[groupName][subjectName] = undefined;
}
else {
const currentValue = this.groups[groupName].value[subjectName].value;
if (currentValue === undefined)
return;
this.groups[groupName].value[subjectName].next(undefined);
this.groupBackups[groupName][subjectName] = undefined;
}
}
}
this.updated.next(subjectName);
}
/**
* Performs deep equality comparison between two values to determine if they are structurally identical.
*
* This utility method implements recursive deep comparison for complex data structures including
* objects, arrays, and primitives. It's used throughout the state management system to prevent
* unnecessary BehaviorSubject emissions when values haven't actually changed, optimizing
* performance and preventing infinite subscription loops.
*
* The comparison follows these rules:
* - Uses strict equality (`===`) for primitives and identical references
* - Considers `null` and `undefined` as distinct values (not equal to each other)
* - Recursively compares array elements in order
* - Recursively compares object properties by key-value pairs
* - Returns `false` for mismatched types or structures
*
* @param value - The first value to compare. Can be any type including primitives,
* objects, arrays, null, or undefined.
* @param other - The second value to compare against. Can be any type including
* primitives, objects, arrays, null, or undefined.
*
* @returns `true` if the values are deeply equal, `false` if they differ in structure
* or content, or `undefined` if the comparison cannot be determined (currently this
* method always returns a boolean).
*
* @example
* Primitive comparisons:
* ```typescript
* this.stateModel.isEqual(5, 5); // true
* this.stateModel.isEqual('hello', 'hi'); // false
* this.stateModel.isEqual(null, undefined); // false
* ```
*
* @example
* Array comparisons:
* ```typescript
* this.stateModel.isEqual([1, 2, 3], [1, 2, 3]); // true
* this.stateModel.isEqual([1, 2], [1, 2, 3]); // false
* this.stateModel.isEqual([[1, 2]], [[1, 2]]); // true (nested arrays)
* ```
*
* @example
* Object comparisons:
* ```typescript
* const obj1 = { name: 'John', age: 30, hobbies: ['read', 'code'] };
* const obj2 = { name: 'John', age: 30, hobbies: ['read', 'code'] };
* const obj3 = { age: 30, name: 'John', hobbies: ['read', 'code'] };
*
* this.stateModel.isEqual(obj1, obj2); // true
* this.stateModel.isEqual(obj1, obj3); // true (key order doesn't matter)
* ```
*
* @example
* Usage in state management optimization:
* ```typescript
* // Prevents unnecessary emissions
* const currentValue = this.dir['userProfile'].value;
* if (!this.isEqual(currentValue, newValue)) {
* this.dir['userProfile'].next(newValue);
* }
* ```
*
* @remarks
* - This method is used internally by {@link StateModel.set}, {@link StateModel.update},
* and {@link StateModel.reset} to optimize performance
* - Handles circular references gracefully by comparing object structure
* - Performance scales with object depth and complexity
* - Does not compare object prototypes or non-enumerable properties
*
* @see {@link StateModel.set} - Uses this method to prevent duplicate state updates
* @see {@link StateModel.update} - Uses this method for change detection
* @see {@link StateModel.dirEquals} - Higher-level equality check for directory objects
*
* @public
* @since 1.0.0
*/
isEqual(value, other) {
// 1. Apply strict equality check for primitive types and identical references
if (value === other)
return true;
// 2. Handle null/undefined cases
if (value === null ||
other === null ||
value === undefined ||
other === undefined) {
return false;
}
// 3. Handle array comparison
if (Array.isArray(value) && Array.isArray(other)) {
if (value.length !== other.length)
return false;
for (let i = 0; i < value.length; i++) {
if (!this.isEqual(value[i], other[i])) {
return false;
}
}
return true;
}
// 4. Handle object comparison
if (typeof value === 'object' && typeof other === 'object') {
// Gather keys from both objects
const valueKeys = Object.keys(value);
const otherKeys = Object.keys(other);
// Return false if number of keys differ
if (valueKeys.length !== otherKeys.length)
return false;
// Check if all keys and values are equal
for (const key of valueKeys) {
if (!otherKeys.includes(key))
return false;
const valueValue = value[key];
const otherValue = other[key];
if (!this.isEqual(valueValue, otherValue))
return false;
}
return true;
}
// 5. If none of the above conditions match, values are not equal
return false;
}
/**
* Creates and initializes a hierarchical group within the state management system.
*
* This method establishes a new group in the {@link StateModel.groups} registry as a
* BehaviorSubject containing a Record of named BehaviorSubjects. The group provides
* hierarchical state organization where both the group container and individual subjects
* within it are reactive. Optionally populates the group with BehaviorSubjects created
* from an array of objects, using a specified property as the subject identifier.
*
* The method performs the following operations:
* - Creates the group BehaviorSubject if it doesn't exist
* - Optionally creates individual BehaviorSubjects from object array using keyName as identifier
* - Updates the reactive {@link StateModel.groupList} registry
* - Maintains referential integrity within the hierarchical state structure
*
* @param groupName - The unique identifier for the group to create or initialize.
* This name will be used as the key in the {@link StateModel.groups} registry.
* @param keyName - Optional property name within objects to use as BehaviorSubject
* identifiers. When provided with `objArray`, this property's value becomes the
* subject name within the group. Must exist as a string or number property in each object.
* @param objArray - Optional array of objects to transform into BehaviorSubjects within
* the group. Each object becomes the initial value of a BehaviorSubject, with the
* subject name derived from the object's `keyName` property.
*
* @example
* Create an empty group:
* ```typescript
* this.stateModel.loadGroup('users');
* // Creates: groups['users'] = BehaviorSubject<Record<string, BehaviorSubject<StateObjType>>>({})
* ```
*
* @example
* Populate group from object array:
* ```typescript
* const userData = [
* { id: 'user1', name: 'John', role: 'admin' },
* { id: 'user2', name: 'Jane', role: 'user' }
* ];
* this.stateModel.loadGroup('users', 'id', userData);
* // Creates subjects: groups['users'].value['user1'], groups['users'].value['user2']
* ```
*
* @see {@link StateModel.groups} - The hierarchical group registry this method modifies
* @see {@link StateModel.groupList} - Reactive list of all group names, updated by this method
* @see {@link StateModel.removeGroup} - Method for removing entire groups
* @see {@link StateModel.observe} - Method for accessing subjects within groups
*
* @public
* @since 1.0.0
*/
loadGroup(groupName, keyName = null, objArray = null) {
if (!this.groups[groupName]) {
this.groups[groupName] = new BehaviorSubject({});
}
if (keyName && objArray && objArray.length > 0) {
const currentGroupValue = { ...this.groups[groupName].value };
for (const obj of objArray) {
// Check if keyName exists in obj and if the value is a string
if (typeof obj === 'object' && obj !== null && keyName in obj) {
const castedObj = obj;
const objName = castedObj[keyName];
if (typeof objName === 'string' || typeof objName === 'number') {
currentGroupValue[objName] = new BehaviorSubject(castedObj);
}
}
}
this.groups[groupName].next(currentGroupValue);
}
if (!this.groupList.value.includes(groupName)) {
const listValue = JSON.parse(JSON.stringify(this.groupList.value));
listValue.push(groupName);
this.groupList.next(listValue);
}
}
/**
* Performs a deep merge of two objects, combining their properties into a new object.
*
* This method recursively merges nested objects while preserving the structure of both input objects.
* Properties from `newObj` take precedence over properties from `oldObj`. For nested plain objects,
* the merge operation continues recursively. For primitives, arrays, dates, and other non-plain
* object types, values from `newObj` completely replace corresponding values from `oldObj`.
*
* The merge operation follows these rules:
* - If `newObj` is not a plain object (null, undefined, array, etc.), returns `oldObj` unchanged
* - If `oldObj` is null or undefined, treats it as an empty object for merging purposes
* - Nested plain objects are merged recursively using the same rules
* - Non-object values (primitives, arrays, dates, etc.) from `newObj` replace values in `oldObj`
* - Returns a new object without mutating the original input objects
*
* @param newObj - The source object whose properties will be merged into the base object.
* If not a plain object, the merge operation returns `oldObj` unchanged.
* @param oldObj - The base object to merge into. Null or undefined values are treated as empty objects.
*
* @returns A new merged object containing properties from both inputs, with `newObj` properties
* taking precedence. Returns `oldObj` unchanged if `newObj` is not a plain object.
*
* @example
* Basic object merging:
* ```typescript
* const base = { name: 'John', age: 30, city: 'New York' };
* const updates = { age: 31, country: 'USA' };
* const result = this.stateModel.merge(updates, base);
* // Result: { name: 'John', age: 31, city: 'New York', country: 'USA' }
* ```
*
* @example
* Nested object merging:
* ```typescript
* const base = { user: { name: 'John', settings: { theme: 'light', lang: 'en' } } };
* const updates = { user: { settings: { theme: 'dark' } } };
* const result = this.stateModel.merge(updates, base);
* // Result: { user: { name: 'John', settings: { theme: 'dark', lang: 'en' } } }
* ```
*
* @example
* Handling non-plain objects:
* ```typescript
* const base = { items: [1, 2, 3], date: new Date('2023-01-01') };
* const updates = { items: [4, 5], date: new Date('2024-01-01') };
* const result = this.stateModel.merge(updates, base);
* // Arrays and dates are replaced, not merged
* // Result: { items: [4, 5], date: new Date('2024-01-01') }
* ```
*
* @example
* State update pattern:
* ```typescript
* // Merge partial user profile updates
* const currentProfile = this.stateModel.value('userProfile');
* const profileUpdates = { preferences: { notifications: false } };
* const mergedProfile = this.stateModel.merge(profileUpdates, currentProfile);
* this.stateModel.set('userProfile', mergedProfile);
* ```
*
* @remarks
* - Uses {@link StateModel.isPlainObject} to determine if objects can be merged recursively
* - Preserves prototype chains and does not modify input objects
* - Performance scales with object depth and number of properties
* - Ideal for implementing partial state updates and configuration merging
*
* @see {@link StateModel.isPlainObject} - Helper method used to identify mergeable objects
* @see {@link StateModel.update} - Method that could benefit from merge operations
* @see {@link StateModel.set} - Method for applying merged results to state
*
* @public
* @since 1.0.0
*/
merge(newObj, oldObj) {
// Enforce the doc's contract
if (!this.isPlainObject(newObj))
return oldObj;
// Base for the merge (treat null/undefined old as empty object)
const base = this.isPlainObject(oldObj) ? oldObj : {};
const merged = { ...base };
for (const key of Object.keys(newObj)) {
const newVal = newObj[key];
const oldVal = base[key];
if (this.isPlainObject(newVal) && this.isPlainObject(oldVal)) {
// Correct argument order: (new, old)
const nested = this.merge(newVal, oldVal);
if (nested !== undefined)
merged[key] = nested;
}
else {
// Replace primitives, arrays, dates, etc. with new
merged[key] = newVal;
}
}
return merged;
}
/**
* Compares an external object with the current value of a state subject for
* deep equality.
*
* This method performs deep equality comparison between a provided object and the current
* value of a BehaviorSubject stored in either the main {@link StateModel.dir} registry
* or within a specific group in {@link StateModel.groups}. It leverages the internal
* {@link StateModel.isEqual} method to provide structural comparison rather than reference
* equality, making it ideal for change detection and validation scenarios.
*
* The comparison process follows this hierarchy:
* 1. First checks for the subject in the main directory registry
* 2. If not found and a group name is provided, searches within the specified group
* 3. Returns `undefined` if the subject doesn't exist in either location
* 4. Performs deep equality comparison if the subject is found
*
* @param obj - The external object to compare against the state subject's current value.
* Can be any type including primitives, objects, arrays, null, or undefined.
* @param dirObjName - The name of the state subject to compare against. This identifier
* is used to locate the BehaviorSubject in either the main directory or specified group.
* @param groupName - Optional group name to limit the search to subjects within a specific
* group. When `null` (default), searches only in the main directory. When specified,
* searches only within the named group if not found in the main directory.
*
* @returns `true` if the objects are deeply equal, `false` if they differ in structure
* or content, or `undefined` if the specified subject doesn't exist in the target location.
*
* @example
* Compare with main directory subject:
* ```typescript
* const userProfile = { id: 1, name: 'John', email: 'john@example.com' };
* const isEqual = this.stateModel.dirEquals(userProfile, 'currentUser');
*
* if (isEqual === true) {
* console.log('Objects are identical');
* } else if (isEqual === false) {
* console.log('Objects differ');
* } else {
* console.log('Subject does not exist');
* }
* ```
*
* @example
* Compare with grouped subject:
* ```typescript
* const settingsData = { theme: 'dark', language: 'en', notifications: true };
* const isEqual = this.stateModel.dirEquals(settingsData, 'preferences', 'user');
*
* // Use result for change detection
* if (isEqual === false) {
* // Settings have changed, update UI or trigger side effects
* this.stateModel.set('preferences', settingsData, 'user');
* }
* ```
*
* @example
* Form validation use case:
* ```typescript
* // Check if form data matches current state before submission
* const formData = this.userForm.value;
* const hasChanges = this.stateModel.dirEquals(formData, 'userProfile') === false;
*
* if (hasChanges) {
* // Enable save button or show unsaved changes indicator
* this.showUnsavedChanges = true;
* }
* ```
*
* @remarks
* - This method is particularly useful for change detection in reactive forms and components
* - The comparison is structural, not referential, allowing for meaningful equality checks
* - Returning `undefined` allows callers to distinguish between "different values" and "subject not found"
* - Performance scales with object complexity due to deep comparison algorithm
*
* @see {@link StateModel.isEqual} - The underlying deep equality comparison method
* @see {@link StateModel.dir} - Main state subject registry searched by this method
* @see {@link StateModel.groups} - Grouped state registry searched when group name is specified
* @see {@link StateModel.has} - Method to check subject existence without value comparison
*
* @public
* @since 1.0.0
*/
dirEquals(obj, dirObjName, groupName = null) {
let dirObj;
// 1. Check if dirObjName exists in dir
if (!groupName && dirObjName in this.dir) {
dirObj = this.dir[dirObjName].value;
}
else if (groupName &&
this.groups[groupName] &&
this.groups[groupName].value[dirObjName]) {
dirObj = this.groups[groupName].value[dirObjName].value;
}
else {
return undefined;
}
// 2. Use isEqual to compare obj and dirObj
const isEqual = this.isEqual(obj, dirObj);
return isEqual;
}
/**
* Merges a new object with the current value of a state subject, updating the subject if changes are detected.
*
* This method provides intelligent merging capabilities by combining a new object with the existing
* value of a BehaviorSubject stored in either the main {@link StateModel.dir} registry or within
* a specific group in {@link StateModel.groups}. It leverages the internal {@link StateModel.merge}
* method for deep merging and automatically updates the state subject only when the merged result
* differs from the current value, providing optimization for unnecessary operations.
*
* The merge operation follows this workflow:
* 1. Locates the target state subject in the main directory or specified group
* 2. If the subject doesn't exist, creates it using {@link StateModel.set} with the new object
* 3. If the subject exists, performs a deep merge of the new object with the current value
* 4. Updates the subject only if the merged result differs from the current value
* 5. Returns the final merged result for further use
*
* This method is ideal for implementing partial state updates, confi