@energica-city/shared-amplify-utils
Version:
Shared utilities for AWS Amplify projects
599 lines • 21 kB
JavaScript
/* eslint-disable max-lines */
import { logger } from '../log';
import CryptoJS from 'crypto-js';
import { ClientManager } from './ClientManager';
//#region IDENTIFIER AND DATA EXTRACTION UTILITIES
/**
* Extracts identifier fields from input data using schema metadata.
*
* Handles both single and composite primary keys by extracting the appropriate
* identifier fields from the input based on the entity's schema definition.
*
* @param input - The input data object containing potential identifier fields
* @param entityName - Name of the entity to extract identifiers for
* @returns Object containing only the identifier fields and their values
*
* @example
* ```typescript
* // Single key
* const id = extractIdentifier({ userId: "123", name: "John" }, "User");
* // Returns: { userId: "123" }
*
* // Composite key
* const compositeId = extractIdentifier(
* { tenantId: "org1", userId: "123", name: "John" },
* "UserProfile"
* );
* // Returns: { tenantId: "org1", userId: "123" }
* ```
*/
export const extractIdentifier = (input, entityName) => {
const identifierFields = ClientManager.getIdentifierFields(entityName);
const identifier = {};
// Extract all identifier fields (handles both single and composite keys)
for (const field of identifierFields) {
if (input[field] !== undefined) {
identifier[field] = input[field];
}
}
// Validate we found at least one identifier field
if (Object.keys(identifier).length === 0) {
return input; // Fallback to full input
}
// For composite keys, we need ALL fields to be present
if (identifierFields.length > 1 &&
Object.keys(identifier).length < identifierFields.length) {
const missingFields = identifierFields.filter(field => !(field in identifier));
logger.warn(`Incomplete composite key for ${entityName}`, {
found: Object.keys(identifier),
missing: missingFields,
required: identifierFields,
});
}
return identifier;
};
//#endregion
//#region LOGGING UTILITIES
// Remove logOperation function entirely
// export const logOperation = ...
/**
* Logs the successful completion of a database operation.
*
* Creates standardized log entries for successful database operations,
* including operation type and optional additional context information.
*
* @internal
* @param operation - The type of database operation that completed
* @param additionalInfo - Optional additional information to include in the log
*
* @example
* ```typescript
* logSuccess("create", { nameStr: "User", id: "123" });
* // Logs: "Successfully created" with additional context
* ```
*/
export const logSuccess = (operation, additionalInfo) => {
const pastTense = operation === 'create'
? 'created'
: operation === 'update'
? 'updated'
: operation === 'delete'
? 'deleted'
: operation === 'get'
? 'retrieved'
: 'listed';
// For create/update/delete operations, include full data serialization
if (operation === 'create' ||
operation === 'update' ||
operation === 'delete') {
logger.info(`Successfully ${pastTense}`, {
operation,
...(additionalInfo ? { additionalInfo } : {}),
});
}
else {
// For get/list operations, keep minimal logging
logger.info(`Successfully ${pastTense}`, {
operation,
...(additionalInfo ? { additionalInfo } : {}),
});
}
};
//#endregion
//#region VALIDATION UTILITIES
/**
* Validates GraphQL-like response objects for correctness and data presence.
*
* Performs comprehensive validation of database responses including:
* - Response object existence and structure
* - GraphQL error detection and reporting
* - Data presence validation
* - Structured error logging with context
*
* @template R - The expected type of the data property in the response
* @param props - Validation configuration object
* @param props.response - The response object to validate
* @param props.operation - The operation that generated this response
* @param props.name - The model name for context
* @param props.input - Optional input data for error context
* @returns The validated data from the response
* @throws {Error} When response is invalid, malformed, or contains errors
*
* @example
* ```typescript
* const userData = validateResponse({
* response: { data: { id: "123", name: "John" }, errors: [] },
* operation: "get",
* name: "User",
* input: { userId: "123" }
* });
* // Returns: { id: "123", name: "John" }
* ```
*/
export const validateResponse = (props) => {
const { response, operation, name, input } = props;
// Validate that response exists
if (response === null || response === undefined) {
const errorMsg = `No response received for ${name} ${operation}`;
logger.error(errorMsg, { input });
throw new Error(errorMsg);
}
// Validate that response has the expected structure
if (typeof response !== 'object' || !('data' in response)) {
const errorMsg = `Invalid response structure for ${name} ${operation}`;
logger.error(errorMsg, { response, input });
throw new Error(errorMsg);
}
const { data, errors } = response;
// Check for GraphQL errors
if (errors && errors.length > 0) {
const errorMessages = errors.map(error => {
const message = error?.message || 'Unknown error';
const errorMessage = `GraphQL error during ${name} ${operation}: ${message}`;
logger.error(errorMessage, { specificError: error, input });
return errorMessage;
});
throw new Error(errorMessages.join('\n'));
}
// Validate data presence
if (data === null || data === undefined) {
const errorMsg = `No data returned for ${name} ${operation}`;
logger.error(errorMsg, {
searchCriteria: input,
operation,
model: name,
});
throw new Error(errorMsg);
}
return data;
};
//#endregion
//#region CLIENT MANAGER CONVENIENCE WRAPPERS
/**
* Convenience wrapper functions that delegate to ClientManager methods.
*
* These provide a simpler API for common use cases while maintaining
* backwards compatibility with the previous function-based interface.
*/
/**
* Retrieves query factories with automatic initialization of missing entities.
*
* Convenience wrapper around ClientManager.getInstance().getQueryFactories()
* that automatically creates query factories for entities that haven't been
* initialized yet.
*
* @template TTypes - Record of all available Amplify model types
* @template TSelected - Selected entity names as string literals
* @param config - Configuration for query factory retrieval
* @param config.entities - Array of entity names to retrieve factories for
* @param config.cache - Optional cache configuration for all factories
* @param config.clientKey - Optional client key for isolation (default: "default")
* @returns Promise resolving to object with query factories for each entity
*
* @example
* ```typescript
* const queries = await getQueryFactories({
* entities: ["User", "Post", "Comment"],
* cache: { enabled: true, maxSize: 50 * 1024 * 1024 },
* clientKey: "main"
* });
*
* const user = await queries.User.get({ input: { userId: "123" } });
* ```
*/
export function getQueryFactories(config) {
const { clientKey = 'default', ...rest } = config;
const manager = ClientManager.getInstance(clientKey);
return manager.getQueryFactories(rest);
}
//#region ERROR CLASSIFICATION UTILITIES
/**
* Safely extracts error messages from unknown error objects.
*
* Handles various error types including Error instances, strings, and other objects
* by attempting to extract a meaningful error message.
*
* @param error - Unknown error object to extract message from
* @returns String representation of the error message
*
* @example
* ```typescript
* try {
* await someOperation();
* } catch (error) {
* const message = getErrorMessage(error);
* console.log(`Operation failed: ${message}`);
* }
* ```
*/
export function getErrorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
/**
* Determines if an error message indicates a "not found" condition.
*
* Specifically checks for the error message generated by our validateResponse()
* function when no data is returned from a database operation.
*
* @param message - Error message to analyze
* @returns True if the message indicates no data was returned
*
* @example
* ```typescript
* // Error from validateResponse when data is null/undefined
* const errorMsg = "No data returned for User get";
* if (isNotFoundError(errorMsg)) {
* // Handle as 404 Not Found
* return RestErrors.notFound("User not found");
* }
* ```
*/
export function isNotFoundError(message) {
return message.includes('No data returned for');
}
/**
* Determines if an error message indicates a validation failure.
*
* Checks error messages against patterns that AWS AppSync returns for validation errors,
* including schema type mismatches, constraint violations, and invalid scalar types.
*
* @param message - Error message to analyze
* @returns True if the message indicates a validation error
*
* @example
* ```typescript
* // AppSync schema validation error
* const errorMsg = "Validation error of type WrongType: argument 'email' with value 'StringValue{value='not-a-valid-email'}' is not a valid 'AWSEmail'";
* if (isValidationError(errorMsg)) {
* // Handle as 400 Bad Request
* return RestErrors.validation("Invalid input data");
* }
*
* // AppSync type mismatch error
* const typeError = "Variable '$input' of type 'CreateUserInput' used in position expecting type 'CreateUserInput!'";
* if (isValidationError(typeError)) {
* return RestErrors.validation("Required field missing");
* }
* ```
*/
export function isValidationError(message) {
const validationPatterns = [
// AppSync schema validation errors
/Validation error of type/i,
/is not a valid/i,
/WrongType:/i,
// AppSync variable type errors
/Variable.*of type.*used in position expecting type/i,
/Expected type.*but was/i,
/Variable.*has an invalid value/i,
// AppSync scalar validation errors (AWSEmail, AWSPhone, etc.)
/not a valid 'AWS/i,
/Invalid value for type/i,
// AppSync field validation errors
/Field.*of required type.*was not provided/i,
/Cannot return null for non-nullable field/i,
// AppSync input validation errors
/Unknown argument.*on field/i,
/Field.*doesn't accept argument/i,
/Missing required argument/i,
// General GraphQL validation patterns that AppSync uses
/validation failed/i,
/schema validation/i,
/input validation/i,
// Legacy patterns for broader compatibility
/invalid/i,
/required/i,
/constraint/i,
/format/i,
];
return validationPatterns.some(pattern => pattern.test(message));
}
/**
* Determines if an error message indicates a data conflict.
*
* Checks error messages against patterns that AWS AppSync and DynamoDB return for
* conflict situations, including conditional check failures, version mismatches,
* and optimistic concurrency control violations.
*
* @param message - Error message to analyze
* @returns True if the message indicates a conflict error
*
* @example
* ```typescript
* // DynamoDB conditional check failure
* const conditionalError = "The conditional request failed";
* if (isConflictError(conditionalError)) {
* // Handle as 409 Conflict
* return RestErrors.conflict("Item already exists or has been modified");
* }
*
* // AppSync version conflict
* const versionError = "ConflictUnhandled: Conflict resolver rejects mutation.";
* if (isConflictError(versionError)) {
* return RestErrors.conflict("Data was modified by another user");
* }
* ```
*/
export function isConflictError(message) {
const conflictPatterns = [
// DynamoDB conditional check failures
/The conditional request failed/i,
/ConditionalCheckFailedException/i,
// AppSync conflict resolution errors
/ConflictUnhandled/i,
/Conflict resolver rejects mutation/i,
/Version mismatch/i,
/version.*mismatch/i,
// AppSync optimistic concurrency control
/OptimisticConcurrencyControl/i,
/version.*conflict/i,
/concurrent modification/i,
// General conflict patterns (for custom resolvers)
/already exists/i,
/duplicate.*key/i,
/unique.*constraint/i,
/primary.*key.*violation/i,
/item.*already.*exists/i,
// Custom conflict resolver patterns
/conflict.*detected/i,
/mutation.*rejected.*due.*to.*conflict/i,
/data.*modified.*by.*another/i,
// Resource conflicts
/resource.*conflict/i,
/operation.*conflict/i,
];
return conflictPatterns.some(pattern => pattern.test(message));
}
//#endregion
//#region CACHE KEY UTILITIES
/**
* Creates deterministic hash strings from objects using schema-aware identifier fields.
*
* Generates consistent cache keys by extracting identifier fields from the schema
* and creating SHA-256 hashes. Falls back to JSON serialization if identifier
* extraction fails.
*
* @param obj - Object to generate hash from
* @param entityName - Name of the entity for identifier field extraction
* @returns SHA-256 hash string for use as cache key
*
* @example
* ```typescript
* const user = { userId: "123", email: "user@example.com", name: "John" };
* const hash = createObjectHash(user, "User");
* // Returns hash of { userId: "123" } (only identifier fields)
*
* const cacheKey = `User:get:${hash}`;
* ```
*/
export function createObjectHash(obj, entityName) {
try {
const identifierFields = ClientManager.getIdentifierFields(entityName);
const identifierData = {};
for (const field of identifierFields) {
if (obj[field] !== undefined) {
identifierData[field] = obj[field];
}
}
const dataToHash = Object.keys(identifierData).length > 0 ? identifierData : obj;
const keys = Object.keys(dataToHash).sort();
const pairs = keys.map(key => `${key}:${String(dataToHash[key])}`);
const serialized = pairs.join('|');
return CryptoJS.SHA256(serialized).toString();
}
catch (error) {
logger.warn(`Hash generation failed for ${entityName}, falling back to JSON`, { error });
return CryptoJS.SHA256(JSON.stringify(obj)).toString();
}
}
//#endregion
//#region QUERY PARAMETER BUILDERS
/**
* Builds parameters object for list operations.
*
* @param params - Configuration for list parameters
* @returns Object containing only the defined parameters
*
* @example
* ```typescript
* const params = buildListParams({
* filter: { status: { eq: 'active' } },
* limit: 50,
* sortDirection: 'desc'
* });
* // Returns: { filter: {...}, limit: 50, sortDirection: 'desc' }
* ```
*/
export function buildListParams(params) {
const result = {};
if (params.filter)
result.filter = params.filter;
if (params.sortDirection)
result.sortDirection = params.sortDirection;
if (params.limit)
result.limit = params.limit;
if (params.authMode)
result.authMode = params.authMode;
if (params.selectionSet)
result.selectionSet = params.selectionSet;
return result;
}
/**
* Builds parameters for index query operations.
*
* @param input - Base input object for the query
* @param params - Additional parameters to merge
* @returns Combined parameter object
*
* @example
* ```typescript
* const params = buildIndexParams(
* { userId: "123" },
* { limit: 20, filter: { active: { eq: true } } }
* );
* // Returns: { userId: "123", limit: 20, filter: {...} }
* ```
*/
export function buildIndexParams(input, params) {
const base = { ...input };
if (params.filter)
base.filter = params.filter;
if (params.limit)
base.limit = params.limit;
if (params.authMode)
base.authMode = params.authMode;
if (params.selectionSet)
base.selectionSet = params.selectionSet;
if (params.nextToken)
base.nextToken = params.nextToken;
return base;
}
//#endregion
//#region CACHE UTILITIES
/**
* Generic cache check for query operations.
*
* Checks the cache for a previously stored query result based on
* the operation type and hash of query parameters.
*
* @template T - Type of the cached result
* @param config - Cache check configuration
* @returns Cached result if available, null otherwise
*
* @example
* ```typescript
* const cached = checkQueryCache<PaginationResult<User>>({
* nameStr: "User",
* cacheType: "list",
* isCacheable: true,
* hashData: { limit: 50 },
* cache: queryCache
* });
* ```
*/
export function checkQueryCache(config) {
if (!config.isCacheable || !config.cache)
return null;
const cacheKey = `${config.nameStr}:${config.cacheType}:${createObjectHash(config.hashData, config.nameStr)}`;
return config.cache.get(cacheKey) || null;
}
/**
* Generic cache set for query operations.
*
* Stores a query result in the cache if caching is eligible based
* on the operation type and parameters.
*
* @template T - Type of the result to cache
* @param config - Cache set configuration
*
* @example
* ```typescript
* setQueryCache({
* nameStr: "User",
* cacheType: "list",
* isCacheable: true,
* hashData: { limit: 50 },
* result: { items: [...], scannedCount: 50 },
* cache: queryCache
* });
* ```
*/
export function setQueryCache(config) {
if (!config.isCacheable || !config.cache)
return;
const cacheKey = `${config.nameStr}:${config.cacheType}:${createObjectHash(config.hashData, config.nameStr)}`;
config.cache.set(cacheKey, config.result);
}
//#endregion
//#region PAGINATION UTILITIES
/**
* Handles pagination consistently across database operations.
*
* Provides automatic pagination following with safety limits and comprehensive
* error handling. Can retrieve all pages automatically or handle single-page
* operations with consistent response formatting.
*
* @template T - Type of items being paginated
* @param operation - Function that performs the paginated operation
* @param params - Parameters to pass to the operation function
* @param options - Pagination behavior configuration
* @param options.followNextToken - Whether to automatically follow all pages
* @param options.maxPages - Safety limit to prevent infinite loops (default: 10)
* @returns Promise resolving to aggregated pagination results
*
* @example
* ```typescript
* // Single page
* const singlePage = await handlePagination(
* (params) => model.list(params),
* { limit: 20 },
* { followNextToken: false }
* );
*
* // All pages
* const allPages = await handlePagination(
* (params) => model.list(params),
* { limit: 100 },
* { followNextToken: true, maxPages: 50 }
* );
* ```
*/
export async function handlePagination(operation, params = {}, options = {}) {
const { followNextToken = false, maxPages = 10 } = options;
let allItems = [];
let currentToken = params.nextToken;
let totalScanned = 0;
let pageCount = 0;
do {
const requestParams = { ...params };
if (currentToken) {
requestParams.nextToken = currentToken;
}
const response = await operation(requestParams);
const { data, errors, nextToken: responseNextToken } = response;
if (errors && errors.length > 0) {
const { throwError } = await import('../error');
throw throwError('Pagination operation failed', errors);
}
if (!Array.isArray(data)) {
const { throwError } = await import('../error');
throw throwError('Invalid pagination response format');
}
const items = data;
allItems = allItems.concat(items);
totalScanned += items.length;
currentToken = responseNextToken;
pageCount++;
if (pageCount >= maxPages) {
logger.warn(`Pagination stopped at ${maxPages} pages to prevent infinite loop`);
break;
}
} while (followNextToken && currentToken);
return {
items: allItems,
...(currentToken ? { nextToken: currentToken } : {}),
scannedCount: totalScanned,
};
}
//#endregion
//# sourceMappingURL=helpers.js.map