qmemory
Version:
A comprehensive production-ready Node.js utility library with MongoDB document operations, user ownership enforcement, Express.js HTTP utilities, environment-aware logging, and in-memory storage. Features 96%+ test coverage with comprehensive error handli
329 lines (302 loc) • 15.3 kB
JavaScript
/**
* Storage Implementations
* Various storage mechanisms for user data
*
* This module provides storage abstractions for user data management.
* Currently implements an in-memory storage solution suitable for development,
* testing, and prototype deployments. The design allows for future extension
* with additional storage backends (Redis, PostgreSQL, etc.) while maintaining
* a consistent interface.
*
* Architecture decisions:
* - Interface-driven design: Enables swapping storage implementations
* - Async methods: Maintains consistency with database-backed implementations
* - Singleton pattern: Provides application-wide shared storage instance
* - Type transformation: Handles conversion between insertion and stored formats
*
* Future extensibility considerations:
* - Additional implementations can be added following the same interface
* - Configuration could be added to select storage backend at runtime
* - Migration utilities could be developed to move between storage types
*/
/**
* In-Memory Storage Implementation - Volatile user storage for development and testing
*
* This implementation provides a simple, fast storage mechanism that doesn't require
* external dependencies. Data is stored in application memory and will be lost when
* the application restarts.
*
* Use cases:
* - Development environments where data persistence isn't critical
* - Testing scenarios that need clean state between test runs
* - Prototype deployments with minimal infrastructure requirements
* - Performance testing where database I/O would be a bottleneck
*
* Limitations:
* - Data loss on application restart (by design for development use)
* - No persistence across deployments or server crashes
* - Memory usage grows with user count (no automatic cleanup)
* - Not suitable for production at scale (single server, no backup)
* - No concurrent access protection (though Node.js is single-threaded)
*
* Performance characteristics:
* - O(1) lookup by ID using Map data structure
* - O(n) lookup by username requiring full scan
* - Minimal latency since all data is in memory
* - No network or disk I/O overhead
*
* Design decisions:
* - Map for O(1) ID access vs. Array for simpler iteration
* - Auto-incrementing IDs for predictability vs. UUIDs for uniqueness
* - Async methods for interface consistency vs. sync for performance
* - Null for missing fields vs. undefined for consistent serialization
*/
class MemStorage {
/**
* Initialize empty storage with starting ID counter
*
* Sets up the internal data structures needed for user storage
* and initializes the ID counter for unique user identification.
*
* Design decisions:
* - Map instead of Object for better key type handling and performance
* - Starting ID at 1 for human-friendly sequential IDs
* - Private fields (via convention) to encapsulate internal state
*/
constructor(maxUsers = 10000) { // optionally cap user count in memory
console.log(`constructor is running with ${maxUsers}`); // trace constructor usage
if (typeof maxUsers !== 'number' || !Number.isInteger(maxUsers) || maxUsers <= 0) { // validate parameter
throw new Error('maxUsers must be a positive integer'); // fail fast on invalid configuration
}
// Initialize backing store using Map for constant time lookups by ID
// Map chosen over Object because numeric keys remain uncoerced and performance is predictable
this.users = new Map(); // Map chosen for O(1) id lookup
// Set first user ID to 1 so each created user gets a simple, sequential identifier
// Auto-incrementing this value avoids collisions and keeps ID generation trivial
this.currentId = 1; // first id value for new users
this.maxUsers = maxUsers; // track maximum users allowed
console.log(`constructor has run resulting in a final value of ${this.maxUsers}`); // log final state
}
/**
* Retrieve user by unique identifier
*
* Provides fast O(1) lookup using the Map data structure.
* Returns undefined for non-existent users rather than throwing errors
* to simplify error handling in consuming code.
*
* Design rationale:
* - Returns undefined instead of null for consistency with Map.get()
* - Async method for interface compatibility with database implementations
* - No error throwing for missing users - let caller decide how to handle
*
* @param {number} id - Unique numeric identifier for the user
* @returns {Promise<Object|undefined>} Promise resolving to User object or undefined if not found
*/
async getUser(id) {
// Input validation for production safety - ensure valid ID type
// Reject non-numeric IDs and IDs less than 1 (our starting ID)
if (typeof id !== 'number' || id < 1) {
return undefined; // Return undefined for invalid IDs to match Map.get() behavior
}
// Map.get gives constant-time access because IDs are stored as map keys
// Keeping the method async mirrors database APIs without blocking the event loop
return this.users.get(id); // Undefined when user doesn't exist keeps caller logic simple
}
/**
* Retrieve user by username for authentication purposes
*
* Performs linear search through all users to find matching username.
* This operation is O(n) but acceptable for development scenarios with
* limited user counts.
*
* Production consideration: For large user bases, this should be optimized
* with a secondary index (Map<string, User>) or replaced with database storage
* that supports indexed queries on username.
*
* Alternative approaches considered:
* - Secondary Map index: Would speed lookups but increase memory usage and complexity
* - Username as primary key: Would lose auto-incrementing numeric IDs
* - Compound data structure: More complex but would support both lookup types efficiently
*
* Current approach chosen for simplicity in development/testing scenarios where
* user count is limited and code clarity is more important than optimization.
*
* @param {string} username - Unique username string for user identification
* @returns {Promise<Object|undefined>} Promise resolving to User object or undefined if not found
*/
async getUserByUsername(username) {
// Input validation for production safety - ensure valid username type
if (typeof username !== 'string' || username.trim().length === 0) {
return undefined; // Invalid usernames return undefined consistently
}
// Convert map values to an array so we can scan each user object by username
// This linear search is slower than ID lookups but fine for small dev datasets
return Array.from(this.users.values()).find(
(user) => user.username === username.trim() // Normalize username with trim
); // Map stores users by ID so username search requires iteration
}
/**
* Normalizes user fields by converting undefined values to null
*
* This helper function ensures consistent data representation by converting
* undefined optional fields to null, which serializes properly to JSON and
* maintains explicit representation of "no value" vs "not provided".
*
* @param {Object} insertUser - User data with optional fields as undefined
* @returns {Object} Normalized user fields with null instead of undefined
*/
normalizeUserFields(insertUser) {
return {
username: insertUser.username.trim(), // remove extraneous spaces for consistent duplicates
displayName: insertUser.displayName ?? null, // Convert undefined to null
githubId: insertUser.githubId ?? null, // Convert undefined to null
avatar: insertUser.avatar ?? null, // Convert undefined to null
};
} // Helper centralizes field normalization logic
/**
* Create new user with auto-generated ID and default field handling
*
* Generates a unique ID for the new user and converts the InsertUser format
* (which may have undefined fields) to the full User format (which uses null
* for missing optional fields).
*
* Field transformation rationale:
* - Converts undefined values to null to match the User type definition
* - Ensures consistent data representation across all storage implementations
* - Null values serialize properly to JSON while undefined values are omitted
* - Maintains explicit representation of "no value" vs "not provided"
*
* ID generation strategy:
* - Uses simple auto-increment for predictable, human-friendly user IDs
* - Sequential IDs are easier to debug and reference in development
* - In production, UUIDs might be preferred for security (no enumeration attacks)
* - Current approach optimizes for development simplicity over production security
*
* @param {Object} insertUser - User data for creation with optional fields as undefined
* @param {string} insertUser.username - Required username for the new user
* @param {string|undefined} insertUser.displayName - Optional display name
* @param {string|undefined} insertUser.githubId - Optional GitHub user ID
* @param {string|undefined} insertUser.avatar - Optional avatar URL
* @returns {Promise<Object>} Promise resolving to complete User object with generated ID
*/
async createUser(insertUser) {
// Input validation for production safety - ensure required fields exist and are valid
// Username is the primary identifier and must be present and non-empty
if (!insertUser || typeof insertUser.username !== 'string' || insertUser.username.trim().length === 0) {
throw new Error('Username is required and must be a non-empty string');
}
const trimmedUsername = insertUser.username.trim(); // single value for duplicate check
if (this.users.size >= this.maxUsers) { // check capacity before async ops
throw new Error('Maximum user limit reached'); // fail fast when full
}
// Check for duplicate username to prevent data conflicts
// This linear search is acceptable for development scenarios with limited users
const existingUser = await this.getUserByUsername(trimmedUsername); // lookup with trimmed value
if (existingUser) {
throw new Error(`Username '${trimmedUsername}' already exists`); // use normalized name in message
}
if (this.users.size >= this.maxUsers) { // enforce limit after async check
throw new Error('Maximum user limit reached'); // stop when capacity full
}
// Generate unique numeric ID then bump the counter for future calls
// Auto-increment keeps IDs sequential which simplifies debugging
const id = this.currentId++;
// Transform InsertUser to User format with proper null handling
// Use helper function to normalize fields consistently across all user operations
const normalizedFields = this.normalizeUserFields({ ...insertUser, username: trimmedUsername }); // ensure stored username lacks spaces
const user = {
id,
...normalizedFields
};
// Store user in Map so lookups by id remain constant time
// Map handles numeric keys without string conversion for speed
this.users.set(id, user);
return user; // Return created user with generated ID for caller use
}
/**
* Get all users for administrative purposes
*
* Returns all stored users as an array. Useful for administrative interfaces,
* debugging, and scenarios where full user lists are needed.
*
* Performance consideration: O(n) operation that creates a new array.
* For large user sets, consider pagination or streaming approaches.
*
* @returns {Promise<Array>} Promise resolving to array of all users
*/
async getAllUsers() {
// Convert Map values to array since Map stores users keyed by ID
// Array.from makes a shallow copy so callers can't mutate internal storage
return Array.from(this.users.values()); // Snapshot of all users for callers
}
/**
* Delete user by ID
*
* Removes a user from storage permanently. Returns boolean to indicate
* whether the user existed and was deleted.
*
* Design decisions:
* - Returns boolean for success/failure indication
* - Uses Map.delete() for O(1) removal
* - Logs operation for debugging and audit trails
* - No cascade deletion (would need to be implemented by caller if needed)
*
* @param {number} id - User ID to delete
* @returns {Promise<boolean>} Promise resolving to true if deleted, false if not found
*/
async deleteUser(id) {
// Input validation for production safety - ensure valid ID type
if (typeof id !== 'number' || id < 1) {
return false; // Invalid IDs return false consistently
}
// Map.delete removes the keyed entry in constant time if it exists
// The boolean return lets the caller know whether anything was actually removed
return this.users.delete(id); // True when user existed and is now gone
}
/**
* Clear all users and reset ID counter
*
* Removes all stored users and resets the ID counter to 1.
* Particularly useful for testing scenarios where clean state
* is needed between test runs.
*
* Design rationale:
* - Resets ID counter to maintain predictable IDs in testing
* - Uses Map.clear() for efficient removal of all entries
* - Async for interface consistency with other storage implementations
*
* @returns {Promise<void>} Promise resolving when clear operation completes
*/
async clear() {
// Remove every entry from the map in one call for efficiency
this.users.clear();
// Reset the auto-increment counter so future creates start at ID 1 again
this.currentId = 1; // Predictable IDs aid repeatable tests
}
}
// Export singleton instance for application-wide use
// This ensures consistent user data across all parts of the application
// and follows the singleton pattern for shared storage state.
//
// Design rationale for singleton:
// - Provides shared storage state across the entire application
// - Eliminates need to pass storage instance through dependency injection
// - Simplifies usage in modules that need user storage
// - Consistent with database connection patterns in many frameworks
//
// Alternative approaches considered:
// - Dependency injection: More flexible but adds complexity
// - Factory pattern: Would allow multiple instances but may cause data fragmentation
// - Module-level storage: Would tightly couple storage to specific modules
//
// Current approach balances simplicity with functionality for development use cases
// Instantiate a single shared storage object so all modules access the same Map
// This avoids state fragmentation that would occur if each import created a new instance
const storage = new MemStorage(); // singleton used across the app
// Export both the class for custom instantiation and the singleton for common use
// Developers can use the provided singleton for convenience or make separate
// instances when isolated state is desired (e.g., in tests)
module.exports = {
MemStorage, // Class export for custom instantiation
storage // Singleton instance for application-wide use
};