UNPKG

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

565 lines (519 loc) 29.1 kB
/** * Document Operation Functions * High-level document manipulation utilities for user-owned documents * * This module provides a comprehensive set of utilities for managing documents * that belong to specific users. It implements common CRUD patterns while * enforcing user ownership constraints and providing consistent error handling. * * Design philosophy: * - User ownership enforcement: All operations verify that users can only access their own documents * - Consistent error handling: Standardized responses for common failure cases (not found, cast errors) * - Composable operations: Higher-level functions build on lower-level primitives * - Logging for debugging: Comprehensive logging helps track operation flow and identify issues * - DRY principle: Common patterns are abstracted to reduce code duplication * * Security considerations: * - All functions require username parameter to prevent unauthorized document access * - MongoDB ObjectId casting errors are handled gracefully to prevent information leakage * - No direct exposure of internal document IDs or database errors to clients * * Performance considerations: * - Uses findOne/findOneAndDelete for single document operations (more efficient than find + filter) * - Leverages MongoDB indexes on _id and user fields for optimal query performance * - Minimal data transfer by only selecting/returning necessary fields */ // Dependencies for database operations and utility functions const mongoose = require('mongoose'); // MongoDB object modeling for Node.js const { sendNotFound } = require('./http-utils'); // Consistent HTTP error responses const { ensureUnique } = require('./database-utils'); // Document uniqueness validation const { logFunctionEntry, logFunctionExit, logFunctionError } = require('./logging-utils'); // Centralized logging /** * Generic document operation executor with error handling * * This function provides a standardized wrapper for document operations that require * user ownership validation. It handles common error cases (like invalid ObjectIds) * and provides consistent logging across all document operations. * * Design rationale: * - Centralizes error handling logic to avoid repetition across specific operations * - Provides consistent logging format for debugging and monitoring * - Handles MongoDB CastError (invalid ObjectId) gracefully by returning null * - Allows any operation function to be wrapped with standard error handling * - User ownership enforcement: passed username ensures queries only touch * documents owned by that user * * Error handling strategy: * - CastError (invalid ObjectId): Return null instead of throwing (client sent bad ID) * - Other errors: Re-throw to allow calling code to handle appropriately * - Logging: Track operation start, result, and any errors for debugging * * Alternative approaches considered: * - Middleware pattern: More complex, would require Express integration * - Decorator pattern: Would require different function signatures * - Try-catch in each function: Would lead to code duplication * * @param {Object} model - Mongoose model to operate on * @param {string} id - Document ID (will be validated as ObjectId) * @param {string} username - Username for ownership verification * @param {Function} opCallback - Operation function to execute with error handling * @returns {Promise<Object|null>} Document result or null if not found/invalid ID */ async function performUserDocOp(model, id, username, opCallback) { console.log(`performUserDocOp is running with id: ${id} user: ${username}`); // Entry logging tracks document operation attempts // Log operation start with parameters for debugging and audit trails // This helps track which users are accessing which documents in production logFunctionEntry(opCallback.name, { id, username }); try { // Execute the provided operation with standard parameters // This pattern allows any document operation to benefit from centralized error handling const doc = await opCallback(model, id, username); // callback performs DB query scoped by username to enforce ownership // Log successful operation result for monitoring and debugging // Helps track successful document operations and return values logFunctionExit(opCallback.name, doc); console.log(`performUserDocOp is returning ${doc}`); // Exit logging shows operation success return doc; // Return document result to calling controller for further processing } catch (error) { // Log error details for debugging while handling gracefully // Centralized error logging ensures consistent error tracking across all document operations logFunctionError(opCallback.name, error); // Handle MongoDB CastError (invalid ObjectId format) by returning null // CastError occurs when client sends malformed ObjectId - return null instead of crashing // This prevents information leakage about valid vs invalid document IDs if (error instanceof mongoose.Error.CastError) { // invalid ObjectId format from client logFunctionExit(opCallback.name, 'null due to CastError'); console.log('performUserDocOp is returning null'); // CastError logging helps debug invalid ID attempts return null; // return null so controllers can respond with 404 without exposing DB errors } // Re-throw other errors to allow proper handling by calling code // Database connection errors, validation errors, etc. need context-specific handling // This design preserves error context while centralizing CastError handling throw error; // Propagate non-CastError exceptions to appropriate error handlers } } // Wrapper centralizes repetitive error handling for all doc operations /** * Finds a document by ID that belongs to a specific user * * This function provides the foundation for all user document retrieval operations. * It ensures that users can only access documents they own, preventing unauthorized * data access through direct ID manipulation. * * Security implementation: * - Queries both _id AND user fields to enforce ownership * - Invalid ObjectIds are handled gracefully without revealing existence of valid IDs * - No information leakage about documents belonging to other users * - User ownership enforcement is done directly in the query filter * * Performance characteristics: * - Uses findOne() which stops at first match * - Relies on compound index on (_id, user) for optimal query performance * - Returns full document - consider field selection for large documents * * @param {Object} model - Mongoose model to query * @param {string} id - Document ID to find * @param {string} username - Username that must own the document * @returns {Promise<Object|null>} Document if found and owned by user, null otherwise */ async function findUserDoc(model, id, username) { console.log(`findUserDoc is running with id: ${id} user: ${username}`); // log start // Define the operation as a named function for better logging and debugging const op = async function findUserDoc(m, i, u) { // Query for document with both ID and user ownership constraints // This ensures users can only access their own documents return m.findOne({ _id: i, user: u }); // ensures query respects ownership and avoids cross-user access }; // Execute with standardized error handling and logging console.log('findUserDoc is returning result from performUserDocOp'); // log before returning wrapped result return performUserDocOp(model, id, username, op); // Use shared wrapper to avoid duplicate try/catch logic } /** * Deletes a document by ID that belongs to a specific user * * This function provides secure document deletion by enforcing user ownership. * It returns the deleted document for confirmation and potential undo operations. * * Design decisions: * - Uses findOneAndDelete for atomic operation (find + delete in single query) * - Returns deleted document to allow confirmation and potential data recovery * - Enforces user ownership to prevent unauthorized deletions * (query includes `user` field so only owner can delete) * * Alternative approaches: * - Soft delete: Mark as deleted instead of removing (would require schema changes) * - Two-step process: find then delete (less efficient, potential race conditions) * - deleteOne: Doesn't return deleted document (less information for caller) * * @param {Object} model - Mongoose model to operate on * @param {string} id - Document ID to delete * @param {string} username - Username that must own the document * @returns {Promise<Object|null>} Deleted document if found and owned by user, null otherwise */ async function deleteUserDoc(model, id, username) { console.log(`deleteUserDoc is running with id: ${id} user: ${username}`); // log start // Define the operation as a named function for clear logging const op = async function deleteUserDoc(m, i, u) { // Use findOneAndDelete for atomic operation with ownership constraint // Returns the deleted document for confirmation return m.findOneAndDelete({ _id: i, user: u }); // deletion only proceeds when username matches document owner }; // Execute with standardized error handling console.log('deleteUserDoc is returning result from performUserDocOp'); // log before return return performUserDocOp(model, id, username, op); } /** * Executes a document action and sends 404 response if document not found * * This function bridges document operations with HTTP response handling, * automatically sending appropriate error responses when documents don't exist * or users don't have access. It centralizes the common pattern of "perform * operation, send 404 if null result". * * Design rationale: * - Reduces boilerplate in controller functions by handling common null-check pattern * - Provides consistent 404 responses across all document operations * - Separates HTTP concerns from pure document operations * - Allows customizable error messages for different contexts * - Works with user-scoped actions so ownership checks happen before HTTP logic * * Usage pattern: This is typically called from Express route handlers where * a null result should trigger a 404 response rather than continuing execution. * * @param {Object} model - Mongoose model to operate on * @param {string} id - Document ID for the operation * @param {string} user - Username for ownership verification * @param {Object} res - Express response object for sending 404 if needed * @param {Function} action - Document operation function to execute * @param {string} msg - Custom message for 404 response * @returns {Promise<Object|undefined>} Document if found, undefined if 404 sent */ async function userDocActionOr404(model, id, user, res, action, msg) { console.log(`userDocActionOr404 is running with id: ${id} user: ${user}`); // log start // Log operation start for debugging and request tracing logFunctionEntry('userDocActionOr404', { id, user }); try { // Execute the provided action (findUserDoc, deleteUserDoc, etc.) const doc = await action(model, id, user); // action includes user filter to enforce ownership // Check if document was found/operation succeeded if (doc == null) { // no doc found for user so send 404 // Send 404 response with custom message and stop execution sendNotFound(res, msg); logFunctionExit('userDocActionOr404', 'undefined'); console.log('userDocActionOr404 is returning undefined'); // log before return return; // undefined return indicates 404 response was sent } // Log successful operation and return document logFunctionExit('userDocActionOr404', doc); console.log(`userDocActionOr404 is returning ${doc}`); // log before returning doc return doc; } catch (error) { // Log error and re-throw for higher-level error handling logFunctionError('userDocActionOr404', error); throw error; } } /** * Fetches a user document or sends a 404 response if not found * * This function combines findUserDoc with automatic 404 handling, providing * a common pattern used throughout controllers for document retrieval with * proper HTTP error responses. * * Design purpose: Eliminates repetitive null-checking code in controllers * by encapsulating the "fetch and 404 if not found" pattern that appears * frequently in REST API implementations. * - Enforces user ownership because findUserDoc filters by both id and user * * @param {Object} model - Mongoose model to query * @param {string} id - Document ID to fetch * @param {string} user - Username that must own the document * @param {Object} res - Express response object for potential 404 response * @param {string} msg - Message to send with 404 response * @returns {Promise<Object|undefined>} Document if found, undefined if 404 sent */ async function fetchUserDocOr404(model, id, user, res, msg) { console.log(`fetchUserDocOr404 is running with id: ${id} user: ${user}`); // log start logFunctionEntry('fetchUserDocOr404', { id, user }); try { // Use the generic action wrapper with findUserDoc as the specific action const doc = await userDocActionOr404(model, id, user, res, findUserDoc, msg); // ensures fetch respects ownership and sends 404 // Check if 404 response was sent (doc would be undefined) if (!doc) { // 404 already sent logFunctionExit('fetchUserDocOr404', 'undefined'); console.log('fetchUserDocOr404 is returning undefined'); // log before return undefined return; } logFunctionExit('fetchUserDocOr404', doc); console.log(`fetchUserDocOr404 is returning ${doc}`); // log before returning doc return doc; } catch (error) { logFunctionError('fetchUserDocOr404', error); throw error; } } /** * Deletes a user document or sends a 404 response if not found * * This function combines deleteUserDoc with automatic 404 handling, providing * the delete equivalent of fetchUserDocOr404. It's commonly used in DELETE * endpoints where the absence of a document should result in a 404 response. * - Ownership enforced through deleteUserDoc which filters by user * * @param {Object} model - Mongoose model to operate on * @param {string} id - Document ID to delete * @param {string} user - Username that must own the document * @param {Object} res - Express response object for potential 404 response * @param {string} msg - Message to send with 404 response * @returns {Promise<Object|undefined>} Deleted document if found, undefined if 404 sent */ async function deleteUserDocOr404(model, id, user, res, msg) { console.log(`deleteUserDocOr404 is running with id: ${id} user: ${user}`); // log start logFunctionEntry('deleteUserDocOr404', { id, user }); try { // Use the generic action wrapper with deleteUserDoc as the specific action const doc = await userDocActionOr404(model, id, user, res, deleteUserDoc, msg); // ensures delete is ownership aware if (!doc) { // 404 already sent logFunctionExit('deleteUserDocOr404', 'undefined'); console.log('deleteUserDocOr404 is returning undefined'); // log before return return; } logFunctionExit('deleteUserDocOr404', doc); console.log(`deleteUserDocOr404 is returning ${doc}`); // log before returning doc return doc; // Provide deleted document back to caller for confirmation } catch (error) { logFunctionError('deleteUserDocOr404', error); throw error; } } /** * Lists all documents owned by a user with optional sorting * * This function provides filtered document listing for user-owned content. * It supports sorting to enable various presentation orders (newest first, * alphabetical, etc.) commonly needed in user interfaces. * * Design considerations: * - Returns array (possibly empty) rather than null for consistent handling * - Supports MongoDB sort syntax for flexible ordering * - Filters by user ownership to maintain security boundaries * (query constrains to `user` field so other users' data is never returned) * - Does not paginate - consider adding pagination for large document sets * * Performance notes: * - Uses index on 'user' field for efficient filtering * - Sort operations may require additional indexes depending on sort fields * - Consider adding field selection for large documents or pagination for large result sets * * @param {Object} model - Mongoose model to query * @param {string} username - Username to filter documents by * @param {Object} sort - MongoDB sort object (e.g., { createdAt: -1 }) * @returns {Promise<Array>} Array of documents owned by the user (may be empty) */ async function listUserDocs(model, username, sort) { console.log(`listUserDocs is running with user: ${username}`); // log start // Log operation with sort configuration for debugging logFunctionEntry('listUserDocs', { username, sort: JSON.stringify(sort) }); try { // Query for all documents owned by user with specified sorting // Uses find() to return all matching documents, not just the first const docs = await model.find({ user: username }).sort(sort); // filter by user so only owner's docs return, then sort as requested logFunctionExit('listUserDocs', docs); console.log(`listUserDocs is returning ${docs}`); // log before return return docs; // Returning array keeps caller logic simple } catch (error) { logFunctionError('listUserDocs', error); throw error; } } /** * Creates a new document after verifying uniqueness constraints * * This function implements the common pattern of checking for duplicates * before creating new documents. It's essential for maintaining data integrity * where business rules require unique values (usernames, email addresses, etc.). * * Design rationale: * - Separates uniqueness checking from document creation for reusability * - Provides consistent 409 Conflict responses for duplicate attempts * - Returns undefined when duplicate found to signal that HTTP response was sent * - Uses new + save pattern for full Mongoose validation and middleware execution * - Uniqueness enforced via ensureUnique helper before document creation * * Race condition considerations: * - Small window between uniqueness check and save where duplicates could occur * - For critical uniqueness, consider database unique indexes as backup protection * - Current approach balances simplicity with adequate protection for most use cases * * @param {Object} model - Mongoose model to create document with * @param {Object} fields - Field values for the new document * @param {Object} uniqueQuery - Query to check for existing documents * @param {Object} res - Express response object for potential duplicate response * @param {string} duplicateMsg - Message to send if duplicate is found * @returns {Promise<Object|undefined>} Created document if successful, undefined if duplicate */ /** * Validates document uniqueness against query constraints * * Single responsibility helper that centralizes uniqueness validation logic * for reuse across create and update operations. Separates validation concern * from document manipulation operations for better testability and maintainability. * * @param {Object} model - Mongoose model to query * @param {Object} uniqueQuery - Query object for uniqueness check * @param {Object} res - Express response object for error responses * @param {string} duplicateMsg - Message to send for duplicate conflicts * @returns {Promise<boolean>} True if unique, false if duplicate exists */ async function validateDocumentUniqueness(model, uniqueQuery, res, duplicateMsg) { return await ensureUnique(model, uniqueQuery, res, duplicateMsg); // checks db for existing doc matching unique fields } /** * Determines if any unique fields are being modified in an update operation * * Single responsibility helper that separates change detection logic from * update operations. Compares current document values with proposed updates * to determine if uniqueness validation is required. * * @param {Object} doc - Current document with existing values * @param {Object} fieldsToUpdate - Object containing proposed field updates * @param {Object} uniqueQuery - Query object defining which fields must be unique * @returns {boolean} True if any unique fields are being changed, false otherwise */ function hasUniqueFieldChanges(doc, fieldsToUpdate, uniqueQuery) { if (!uniqueQuery) return false; // no unique fields specified so no validation needed return Object.keys(uniqueQuery).some( (key) => key in fieldsToUpdate && doc[key] !== fieldsToUpdate[key] // detect change to any unique field ); } async function createUniqueDoc(model, fields, uniqueQuery, res, duplicateMsg) { console.log('createUniqueDoc is running'); // Entry logging tracks document creation attempts logFunctionEntry('createUniqueDoc', { fields: JSON.stringify(fields) }); try { // Check for existing documents matching uniqueness criteria before creating // Uses extracted helper function to centralize validation logic if (!(await validateDocumentUniqueness(model, uniqueQuery, res, duplicateMsg))) { // duplicate found, response handled inside // ensureUnique already sent 409 Conflict response to client // Return undefined to signal that HTTP response was already sent logFunctionExit('createUniqueDoc', 'undefined'); console.log('createUniqueDoc is returning undefined'); // Undefined return indicates duplicate found return; // Early return prevents document creation when duplicate exists } // Create new document instance with provided fields // Using constructor pattern allows Mongoose validation and middleware to run const doc = new model(fields); // instantiate to run schema validators // Save to database with full validation and middleware execution // save() triggers pre/post hooks and runs all validation rules const saved = await doc.save(); // persist to DB and trigger hooks logFunctionExit('createUniqueDoc', saved); console.log(`createUniqueDoc is returning ${saved}`); // Success logging shows created document return saved; // Return persisted document with generated ID and timestamps } catch (error) { // Log and re-throw errors for higher-level error handling // Database errors, validation errors, etc. need context-specific handling logFunctionError('createUniqueDoc', error); throw error; // Propagate errors to calling code for appropriate response } } /** * Updates a user-owned document with optional uniqueness validation * * This function provides comprehensive document updating with user ownership * enforcement and optional uniqueness checking. It handles the complex logic * of determining when uniqueness validation is needed and updating documents * atomically. * * Design complexity rationale: * - Fetches existing document first to enable ownership verification * - Performs uniqueness check only when relevant fields are being changed * - Uses Object.assign for partial updates while preserving unchanged fields * - Calls save() to trigger Mongoose validation and middleware * - User ownership enforced by fetching document with user filter * - Uniqueness validation uses ensureUnique when relevant fields change * * Uniqueness check optimization: * - Only validates uniqueness when unique fields are actually being modified * - Compares current vs new values to avoid unnecessary database queries * - Supports compound uniqueness constraints through query object * * Alternative approaches considered: * - findOneAndUpdate: More efficient but bypasses some Mongoose features * - Optimistic locking: More complex but would handle race conditions better * - Separate validation: Would require additional parameter passing * * @param {Object} model - Mongoose model to update * @param {string} id - Document ID to update * @param {string} username - Username that must own the document * @param {Object} fieldsToUpdate - Object containing fields to update * @param {Object} uniqueQuery - Query for uniqueness check (optional) * @param {Object} res - Express response object for error responses * @param {string} duplicateMsg - Message for duplicate responses * @returns {Promise<Object|undefined>} Updated document if successful, undefined if error response sent */ async function updateUserDoc(model, id, username, fieldsToUpdate, uniqueQuery, res, duplicateMsg) { console.log(`updateUserDoc is running with id: ${id} user: ${username}`); // Entry logging tracks update attempts logFunctionEntry('updateUserDoc', { id, username }); try { const updates = { ...fieldsToUpdate }; // copy to avoid mutating caller data if (Object.prototype.hasOwnProperty.call(updates, 'user')) { // check for attempted ownership change console.warn(`updateUserDoc ignored user change for doc: ${id}`); // warn when user field is present delete updates.user; // prevent modification of document ownership } // First, fetch the existing document to verify ownership and enable field comparison // This ensures users can only update documents they own and provides current values for comparison const doc = await fetchUserDocOr404(model, id, username, res, 'Document not found'); // ensures caller owns document // If document not found, fetchUserDocOr404 already sent 404 response to client // Return undefined to signal that HTTP response was already sent if (!doc) { // fetchUserDocOr404 already handled response logFunctionExit('updateUserDoc', 'undefined'); console.log('updateUserDoc is returning undefined'); // Not found logging helps debug missing documents return; // Early return prevents further processing when document doesn't exist } // Check if uniqueness validation is needed using extracted helper function // This separates change detection logic from update operations for better maintainability if (hasUniqueFieldChanges(doc, updates, uniqueQuery)) { // only check DB when unique fields change // Build uniqueness query excluding the current document to avoid false positives // Without $ne exclusion, the document would conflict with itself during updates const uniqueQueryWithExclusion = { ...uniqueQuery, _id: { $ne: doc._id } }; // Validate uniqueness using extracted helper function for consistent behavior // This centralizes uniqueness validation logic across create and update operations if (!(await validateDocumentUniqueness(model, uniqueQueryWithExclusion, res, duplicateMsg))) { // duplicate of other doc found logFunctionExit('updateUserDoc', 'undefined'); console.log('updateUserDoc is returning undefined'); // Duplicate logging shows uniqueness conflict return; // Early return when uniqueness validation fails - response already sent } } // Apply updates to document using Object.assign for partial update // Object.assign preserves unchanged fields while updating only specified fields // This pattern allows selective updates without overwriting entire document Object.assign(doc, updates); // merge new fields without dropping existing data // Save updated document to trigger validation and middleware // save() ensures all Mongoose validation rules and pre/post hooks execute await doc.save(); // run validation and persist updates logFunctionExit('updateUserDoc', doc); console.log(`updateUserDoc is returning ${doc}`); // Success logging shows updated document return doc; // Return updated document with new values for downstream processing } catch (error) { // Log and re-throw errors for context-specific error handling // Update errors, validation errors, etc. need appropriate handling by calling code logFunctionError('updateUserDoc', error); throw error; // Propagate errors to calling code for proper error response } } // Export all functions for use by other modules // This comprehensive set of document operations provides building blocks // for most user-document CRUD scenarios while maintaining security and consistency module.exports = { performUserDocOp, // wraps operations with error handling findUserDoc, // fetches a user owned doc deleteUserDoc, // removes a user owned doc userDocActionOr404, // performs action and sends 404 on miss fetchUserDocOr404, // fetch with auto 404 deleteUserDocOr404, // delete with auto 404 listUserDocs, // list docs by user createUniqueDoc, // create with uniqueness check updateUserDoc, // update with optional uniqueness validateDocumentUniqueness, // helper for uniqueness validation hasUniqueFieldChanges // helper for change detection };