mediumroast_api
Version:
Mediumroast for Git(Hub) SDK covering all categories of function.
1,003 lines (883 loc) • 33.2 kB
JavaScript
/**
* Base class for objects in the mediumroast.io backend
* @author Michael Hay <michael.hay@mediumroast.io>
* @file baseObjects.js
* @copyright 2025 Mediumroast, Inc. All rights reserved.
* @license Apache-2.0
* @version 3.0.0
*
* @exports BaseObjects
*/
// Import required modules
import GitHub from '../github.js';
import { isEmpty, isArray, deepClone } from '../../utils/helpers.js';
import { CacheManager } from './cache.js';
import { logger } from './logger.js';
import { createHash } from 'crypto';
import { Octokit } from '@octokit/core';
// Create a singleton cache instance for shared caching
// const sharedCache = new CacheManager();
export class BaseObjects {
/**
* @constructor
* @param {string} token - GitHub API token
* @param {string} org - GitHub organization name
* @param {string} processName - Process name for locking
* @param {string} objType - Object type
*/
constructor(token, org, processName, objType) {
this.token = token;
this.org = org;
this.processName = processName;
this.objType = objType || 'BaseObject';
// Initialize GitHub API client
this.serverCtl = new GitHub(this.token, this.org, this.processName);
// Initialize cache manager
this.cache = new CacheManager();
// Initialize cache keys - must do this before adding specialized keys
this._cacheKeys = {
all: `${this.objType}_all`,
byName: `${this.objType}_by_name`,
byAttribute: `${this.objType}_by_attribute`,
};
// Initialize cache timeouts
this.cacheTimeouts = {
all: 300000, // 5 minutes for all objects
byName: 300000, // 5 minutes for specific objects
byAttribute: 300000, // 5 minutes for attribute queries
};
// Define object file names for containers
this.objectFiles = {
Studies: 'Studies.json',
Companies: 'Companies.json',
Interactions: 'Interactions.json',
Users: 'Users.json' // Add users even though GitHub API doesn't store it the same way
};
// Define field whitelists centrally
this.whitelists = {
Companies: [
'description', 'company_type', 'url', 'role', 'wikipedia_url', 'status', 'logo_url',
'region', 'country', 'city', 'state_province', 'zip_postal', 'street_address', 'latitude', 'longitude', 'phone',
'google_maps_url', 'google_news_url', 'google_finance_url', 'google_patents_url',
'cik', 'stock_symbol', 'stock_exchange', 'recent_10k_url', 'recent_10q_url', 'firmographic_url', 'filings_url', 'owner_tranasactions',
'industry', 'industry_code', 'industry_group_code', 'industry_group_description', 'major_group_code', 'major_group_description',
'linked_interactions', 'linked_studies'
],
Interactions: [
'status', 'content_type', 'file_size', 'reading_time', 'word_count', 'page_count', 'description', 'abstract',
'region', 'country', 'city', 'state_province', 'zip_postal', 'street_address', 'latitude', 'longitude',
'public', 'groups', 'linked_studies'
],
Studies: [
'description', 'status', 'public', 'groups', 'linked_companies', 'linked_interactions'
]
};
// For transaction tracking
this._transactionDepth = 0;
// Set up cache key naming
this._cacheKeys = {
container: `container_${this.objType}`,
search: `search_${this.objType}`,
byName: `${this.objType}_byName`,
byAttribute: `${this.objType}_byAttribute`
};
// Log initialization
logger.debug(`Initialized ${objType} with org: ${org}`);
}
/**
* Invalidate cache entries when data is modified
* @private
*/
_invalidateCache() {
// Invalidate the main container cache which will cascade to all dependent caches
this.cache.invalidate(this._cacheKeys.container);
// Also forward to github.js cache invalidation if it exists
if (this.serverCtl.invalidateCache) {
this.serverCtl.invalidateCache(this._cacheKeys.container);
}
logger.debug(`Cache invalidated for ${this.objType}`);
}
/**
* Creates a standardized error response
* @private
* @param {String} message - Error message
* @param {Object} data - Error data
* @param {Number} statusCode - HTTP status code
* @returns {Array} Standardized error response
*/
_createError(message, data = null, statusCode = 400) {
return [false, { status_code: statusCode, status_msg: message }, data];
}
/**
* Creates a standardized success response
* @private
* @param {String} message - Success message
* @param {Object} data - Response data
* @param {Number} statusCode - HTTP status code
* @returns {Array} Standardized success response
*/
_createSuccess(message, data = null, statusCode = 200) {
logger.debug(message);
return [true, { status_code: statusCode, status_msg: message }, data];
}
/**
* Creates a standardized warning response
* @private
* @param {String} message - Warning message
* @param {Object} data - Response data
* @param {Number} statusCode - HTTP status code
* @returns {Array} Standardized warning response
*/
_createWarning(message, data = null, statusCode = 200) {
logger.warn(message, { data });
return [true, { status_code: statusCode, status_msg: message }, data];
}
/**
* Validates parameters against expected types
* @private
* @param {Object} params - Parameters to validate
* @param {Object} expectedTypes - Expected types for each parameter
* @returns {Array|null} Error response or null if valid
*/
_validateParams(params, expectedTypes) {
for (const [name, value] of Object.entries(params)) {
const expectedType = expectedTypes[name];
if (!expectedType) continue;
if (expectedType === 'array') {
if (!isArray(value)) {
return this._createError(`Invalid parameter: [${name}] must be an array`, null, 400);
}
} else if (expectedType === 'object') {
if (typeof value !== 'object' || value === null) {
return this._createError(`Invalid parameter: [${name}] must be an object`, null, 400);
}
} else if (expectedType === 'string') {
if (typeof value !== 'string' || isEmpty(value)) {
return this._createError(`Invalid parameter: [${name}] must be a non-empty string`, null, 400);
}
} else if (expectedType === 'boolean' && typeof value !== 'boolean') {
return this._createError(`Invalid parameter: [${name}] must be a boolean`, null, 400);
}
}
return null; // No validation errors
}
/**
* Executes a series of operations as a transaction
* @private
* @param {Array<Function>} operations - Array of async functions to execute
* @param {String} transactionName - Name of the transaction for logging
* @returns {Promise<Array>} Result of the transaction
*/
async _executeTransaction(operations, transactionName) {
this._transactionDepth++;
const transactionId = `${transactionName}-${Date.now()}-${this._transactionDepth}`;
let results = [];
// Track the transaction
const tracking = logger.trackTransaction ?
logger.trackTransaction(transactionName) :
{ end: () => {} };
try {
for (let i = 0; i < operations.length; i++) {
const operation = operations[i];
const operationName = operation.name || `Step${i+1}`;
try {
// Pass accumulated data to each operation
const result = await operation(i > 0 ? results[i-1][2] : null);
results.push(result);
if (!result[0]) {
// Operation failed, abort transaction
let errorMessage = 'Unknown error';
// Handle different error message formats
if (result[1]) {
if (typeof result[1] === 'string') {
errorMessage = result[1];
} else if (result[1].status_msg) {
errorMessage = result[1].status_msg;
} else if (result[1].message) {
errorMessage = result[1].message;
} else if (result[1].error && result[1].error.message) {
errorMessage = result[1].error.message;
} else {
// If it's an object without clear message, stringify it properly
try {
errorMessage = JSON.stringify(result[1], null, 2);
} catch (stringifyError) {
errorMessage = `Error object could not be stringified: ${String(result[1])}`;
}
}
}
// Also check result[2] for mrJson and other data structures
if (result[2]) {
if (result[2].mrJson && typeof result[2].mrJson === 'string') {
errorMessage += ` | Data: ${result[2].mrJson}`;
} else if (result[2].message) {
errorMessage += ` | Message: ${result[2].message}`;
} else if (result[2].error) {
errorMessage += ` | Error: ${JSON.stringify(result[2].error)}`;
}
}
// Also include the raw result data for debugging
logger.error('Transaction step failed with raw result:', {
transactionName,
operationName,
result: result,
resultStructure: {
success: result[0],
message: typeof result[1],
data: typeof result[2],
messageKeys: result[1] ? Object.keys(result[1]) : [],
dataKeys: result[2] ? Object.keys(result[2]) : []
}
});
return this._createError(
`Transaction [${transactionName}] failed at step [${operationName}]: ${errorMessage}`,
{
transactionId,
failedStep: operationName,
stepResult: result,
completedSteps: i
},
result[1]?.status_code || 500
);
}
} catch (err) {
return this._createError(
`Transaction [${transactionName}] failed at step [${operationName}]: ${err.message}`,
{
transactionId,
failedStep: operationName,
error: err,
completedSteps: i
},
500
);
}
}
// All operations succeeded
return this._createSuccess(
`Transaction [${transactionName}] completed successfully`,
results[results.length - 1][2]
);
} finally {
this._transactionDepth--;
tracking.end();
}
}
/**
* Enhanced search functionality with better filtering options
* @param {Object} filters - Filter criteria
* @param {Object} options - Search options (limit, sort, etc)
* @returns {Promise<Array>} Search results
*/
async search(filters = {}, options = { limit: 0, sort: null, descending: false }) {
// Create a cache key based on filters and options
const filterKey = JSON.stringify(filters);
const optionsKey = JSON.stringify(options);
const cacheKey = `${this._cacheKeys.search}_${filterKey}_${optionsKey}`;
// Track this operation
const tracking = logger.trackOperation ?
logger.trackOperation(this.objType, 'search') :
{ end: () => {} };
try {
// Use the cache with dependencies to the container
return await this.cache.getOrFetch(
cacheKey,
async () => {
// Get all objects
const allObjectsResp = await this.getAll();
if (!allObjectsResp[0]) {
return allObjectsResp;
}
const allObjects = allObjectsResp[2].mrJson;
if (allObjects.length === 0) {
return this._createError(
`No ${this.objType} found`,
null,
404
);
}
// Apply filters
let results = [...allObjects];
for (const [field, value] of Object.entries(filters)) {
results = results.filter(obj => {
if (field === 'name' && typeof value === 'string') {
return obj.name.toLowerCase().includes(value.toLowerCase());
}
return obj[field] === value;
});
}
// Apply sorting
if (options.sort && results.length > 0) {
const sortField = options.sort;
results.sort((a, b) => {
if (!a[sortField]) return 1;
if (!b[sortField]) return -1;
if (typeof a[sortField] === 'string') {
return options.descending
? b[sortField].localeCompare(a[sortField])
: a[sortField].localeCompare(b[sortField]);
}
return options.descending
? b[sortField] - a[sortField]
: a[sortField] - b[sortField];
});
}
// Apply limit
if (options.limit > 0) {
results = results.slice(0, options.limit);
}
return this._createSuccess(
`Found ${results.length} ${this.objType}`,
results
);
},
this.cacheTimeouts[this.objType] || 60000,
[this._cacheKeys.container] // This search depends on the container data
);
} finally {
tracking.end();
}
}
/**
* @async
* @function getAll
* @description Get all objects from the mediumroast.io application
* @returns {Array} the results from the called function mrRest class
*/
async getAll() {
// Track this operation
const tracking = logger.trackOperation ?
logger.trackOperation(this.objType, 'getAll') :
{ end: () => {} };
try {
// Use cache with the container key
return await this.cache.getOrFetch(
this._cacheKeys.container,
() => this.serverCtl.readObjects(this.objType),
this.cacheTimeouts[this.objType] || 60000,
[] // No dependencies for the main container
);
} catch (error) {
return this._createError(
`Failed to retrieve ${this.objType}: ${error.message}`,
error,
500
);
} finally {
tracking.end();
}
}
/**
* @async
* @function findByName
* @description Find all objects by name from the mediumroast.io application
* @param {string} name - The name to search for
* @param {boolean} fuzzy - Whether to perform fuzzy search (partial string matching). Default: false
* @param {Object} allObjects - Optional pre-fetched objects to search within
* @returns {Promise<Array>} Array containing [success, statusObject, results]
*/
async findByName(name, fuzzy = true, allObjects = null) {
const tracking = logger.trackOperation ?
logger.trackOperation(this.objType, 'findByName') :
{ end: () => {} };
try {
return await this.findByX('name', name, allObjects, fuzzy);
} finally {
tracking.end();
}
}
/**
* @async
* @function findById
* @description Find all objects by id from the mediumroast.io application
* @deprecated
*/
// eslint-disable-next-line no-unused-vars
async findById(_id) {
logger.warn?.('Method findById is deprecated');
return this._createError('Method findById is deprecated', null, 410);
}
/**
* @async
* @function findByX
* @description Find all objects by attribute and value pair
* @param {string} attribute - The attribute to search by
* @param {*} value - The value to search for
* @param {Object} allObjects - Optional pre-fetched objects to search within
* @param {boolean} fuzzy - Whether to perform fuzzy search (partial string matching). Default: false
* @returns {Promise<Array>} Array containing [success, statusObject, results]
*/
async findByX(attribute, value, allObjects = null, fuzzy = false) {
// Create a cache key for this operation (include fuzzy flag in cache key)
const cacheKey = `${this._cacheKeys.byAttribute}_${attribute}_${value}_${fuzzy}`;
// Track this operation
const tracking = logger.trackOperation ?
logger.trackOperation(this.objType, 'findByX') :
{ end: () => {} };
try {
// Validate parameters first before using cache
const validationError = this._validateParams(
{ attribute, value },
{ attribute: 'string' }
);
if (validationError) return validationError;
// Use cache with dependencies to the container
return await this.cache.getOrFetch(
cacheKey,
async () => {
// Convert name values to lowercase for case-insensitive matching
if(attribute === 'name') {
value = typeof value === 'string' ? value.toLowerCase() : value;
}
let myObjects = [];
// If no objects provided, fetch them
if(allObjects === null) {
const allObjectsResp = await this.getAll();
if (!allObjectsResp[0]) {
return allObjectsResp;
}
allObjects = allObjectsResp[2].mrJson;
}
// If the length of allObjects is 0 then return an error
if(allObjects.length === 0) {
return this._createError(`No ${this.objType} found`, null, 404);
}
// Search for matching objects
for(const obj in allObjects) {
let currentObject;
attribute == 'name' ?
currentObject = allObjects[obj][attribute]?.toLowerCase() :
currentObject = allObjects[obj][attribute];
// Skip if current object value is null or undefined
if(currentObject === null || currentObject === undefined) {
continue;
}
// Perform exact or fuzzy matching based on fuzzy flag
let isMatch = false;
if(fuzzy && typeof currentObject === 'string' && typeof value === 'string') {
// Fuzzy search: check if the value is contained within the current object
isMatch = currentObject.includes(value);
} else {
// Exact search: direct equality comparison
isMatch = currentObject === value;
}
if(isMatch) {
myObjects.push(allObjects[obj]);
}
}
if (myObjects.length === 0) {
const searchType = fuzzy ? 'containing' : 'equal to';
return this._createError(
`No ${this.objType} found where ${attribute} is ${searchType} ${value}`,
null,
404
);
} else {
const searchType = fuzzy ? 'containing' : 'equal to';
return this._createSuccess(
`Found ${myObjects.length} objects where ${attribute} is ${searchType} ${value}`,
myObjects
);
}
},
this.cacheTimeouts[this.objType] || 60000,
[this._cacheKeys.container] // This operation depends on the container data
);
} finally {
tracking.end();
}
}
/**
* @async
* @function createObj
* @description Create objects in the mediumroast.io application
*/
async createObj(objs) {
// Track this operation
logger.trackOperation(this.objType, 'createObj');
// Validate parameters
const validationError = this._validateParams(
{ objs },
{ objs: 'array' }
);
if (validationError) return validationError;
// Use transaction pattern for safer operations
let containerData = null; // Store container data for use in later steps
return this._executeTransaction([
// Step 1: Catch container
async () => {
let repoMetadata = {
containers: {
[this.objType]: {}
},
branch: {}
};
const result = await this.serverCtl.catchContainer(repoMetadata);
if (result[0]) {
containerData = result[2]; // Store container data for later use
}
return result;
},
// Step 2: Get SHA
async (data) => {
return await this.serverCtl.getSha(
this.objType,
this.objectFiles[this.objType],
data.branch.name
);
},
// Step 3: Merge and write objects
async (sha) => {
// Use stored container data and the SHA from previous step
const mergedObjects = [...containerData.containers[this.objType].objects, ...objs];
// Write the new objects to the container
return await this.serverCtl.writeObject(
this.objType,
mergedObjects,
containerData.branch.name,
sha
);
},
// Step 4: Release container
async () => {
// Release the container using stored container data
const result = await this.serverCtl.releaseContainer(containerData);
// Invalidate cache if successful
if (result[0]) {
this._invalidateCache();
}
return this._createSuccess(
`Created [${objs.length}] ${this.objType}`,
null
);
}
], `create-${this.objType}`);
}
/**
* @async
* @function updateObj
* @description Update an object in the mediumroast.io application
*/
async updateObj(objToUpdate, dontWrite=false, system=false) {
// Track this operation
logger.trackOperation(this.objType, 'updateObj');
// Extract object data
const { name, key, value } = objToUpdate;
// Validate parameters
const validationError = this._validateParams(
{ name, key },
{ name: 'string', key: 'string' }
);
if (validationError) return validationError;
// Get whitelist for this object type
const whitelist = this.whitelists[this.objType] || [];
// Use github.js updateObject with proper parameter sequence
const result = await this.serverCtl.updateObject(
this.objType,
name,
key,
value,
dontWrite,
system,
whitelist
);
// Invalidate cache if the update was successful
if (result[0] && !dontWrite) {
this._invalidateCache();
}
return result;
}
/**
* @async
* @function deleteObj
* @description Delete an object in the mediumroast.io application
*/
async deleteObj(objName, source, repoMetadata=null, catchIt=true) {
// Track this operation
logger.trackOperation(this.objType, 'deleteObj');
// Validate parameters
const validationError = this._validateParams(
{ objName },
{ objName: 'string' }
);
if (validationError) return validationError;
// Delegate to github.js
const result = await this.serverCtl.deleteObject(
objName,
source,
repoMetadata,
catchIt
);
// Invalidate cache if successful
if (result[0]) {
this._invalidateCache();
}
return result;
}
/**
* Perform batch updates on multiple objects
* @param {Array} updates - Array of update operations
* @returns {Promise<Array>} Results of the update operations
*/
async batchUpdate(updates) {
// Track this operation
logger.trackOperation(this.objType, 'batchUpdate');
// Validate parameters
const validationError = this._validateParams(
{ updates },
{ updates: 'array' }
);
if (validationError) return validationError;
// Get whitelist for this object type
const whitelist = this.whitelists[this.objType] || [];
// Create the repo metadata object for transaction
let repoMetadata = {
containers: {
[this.objType]: {}
},
branch: {}
};
// Execute a transaction for batch updates
return this._executeTransaction([
// Step 1: Catch container
async () => await this.serverCtl.catchContainer(repoMetadata),
// Step 2: Get SHA
async (data) => await this.serverCtl.getSha(
this.objType,
this.objectFiles[this.objType],
data.branch.name
),
// Step 3: Read objects
async () => {
return await this.serverCtl.readObjects(this.objType);
},
// Step 4: Apply all updates
async (objects) => {
// Make deep copy to prevent unintended side effects
const updatedObjects = deepClone(objects.mrJson);
for (const update of updates) {
const { name, key, value, system = false } = update;
// Skip if missing required data
if (isEmpty(name) || isEmpty(key)) continue;
// Skip unauthorized updates
if (!system && whitelist.indexOf(key) === -1) continue;
// Find and update the object
let found = false;
for (const i in updatedObjects) {
if (updatedObjects[i].name === name) {
found = true;
updatedObjects[i][key] = value;
updatedObjects[i].modification_date = new Date().toISOString();
break;
}
}
if (!found) {
return this._createError(
`Object with name [${name}] not found`,
null,
404
);
}
}
// Store updated objects for next step
this._tempObjects = updatedObjects;
return this._createSuccess('Applied all updates');
},
// Step 5: Write updated objects
async (data) => await this.serverCtl.writeObject(
this.objType,
this._tempObjects,
data.branch.name,
data.containers[this.objType].objectSha
),
// Step 6: Release container
async (data) => {
const result = await this.serverCtl.releaseContainer(data);
// Invalidate cache if successful
if (result[0]) {
this._invalidateCache();
}
return this._createSuccess(
`Updated [${updates.length}] objects in [${this.objType}]`
);
}
], `batch-update-${this.objType}`);
}
/**
* @async
* @function linkObj
* @description Link objects in the mediumroast.io application
*/
linkObj(objs) {
// Track this operation
logger.trackOperation(this.objType, 'linkObj');
// Validate parameters
const validationError = this._validateParams(
{ objs },
{ objs: 'array' }
);
if (validationError) return validationError;
let linkedObjs = {};
for(const obj in objs) {
const objName = objs[obj].name;
const sha256Hash = createHash('sha256').update(objName).digest('hex');
linkedObjs[objName] = sha256Hash;
}
return linkedObjs;
}
/**
* Check if a container is locked
* @returns {Promise<Array>} Lock status
*/
async checkForLock() {
// Track this operation
logger.trackOperation(this.objType, 'checkForLock');
return await this.serverCtl.checkForLock(this.objType);
}
/**
* Get the latest commit status for a branch
* @param {string} branchName - Name of branch to check (default: 'main')
* @param {string} repo - Name of the repository (default: 'Megaroast_discovery')
* @returns {Promise<Array>} Latest commit information
*/
async getBranchStatus(branchName = 'main', repo = 'MegaRoast_discovery') {
// Track this operation
const tracking = logger.trackOperation ?
logger.trackOperation(this.objType, 'getBranchStatus') :
{ end: () => {} };
try {
// Create a cache key based on branch and repo
const cacheKey = `branch_status_${repo}_${branchName}`;
// Use cache with a short timeout since this is used for freshness checks
return await this.cache.getOrFetch(
cacheKey,
async () => {
try {
// Initialize Octokit with the token
const octokit = new Octokit({
auth: this.token
});
// Make GitHub API request using Octokit
const response = await octokit.request('GET /repos/{owner}/{repo}/commits', {
owner: this.org,
repo: repo,
sha: branchName,
per_page: 1,
headers: {
'X-GitHub-Api-Version': '2022-11-28'
}
});
if (response.data.length === 0) {
return this._createError(
`No commits found in branch '${branchName}'`,
{ branchName, repo },
404
);
}
// Format the response to include useful information
const latestCommit = response.data[0];
const branchStatus = {
sha: latestCommit.sha,
commit: {
message: latestCommit.commit.message,
author: latestCommit.commit.author,
committer: latestCommit.commit.committer
},
html_url: latestCommit.html_url,
timestamp: latestCommit.commit.committer.date,
branch: branchName,
repository: repo
};
return this._createSuccess(
`Retrieved latest commit for branch '${branchName}'`,
branchStatus
);
} catch (error) {
// Check for specific Octokit error types
if (error.status === 404) {
return this._createError(
`Repository or branch not found: ${this.org}/${repo}/${branchName}`,
error,
404
);
} else if (error.status === 401 || error.status === 403) {
return this._createError(
`Authentication error accessing ${this.org}/${repo}/${branchName}`,
error,
error.status
);
}
// General error handling
return this._createError(
`Failed to get branch status: ${error.message}`,
error,
error.status || 500
);
}
},
60000, // Cache for 1 minute
[] // No dependencies
);
} catch (error) {
return this._createError(
`Failed to get branch status: ${error.message}`,
error,
500
);
} finally {
tracking.end();
}
}
/**
* Check if the branch has been updated since a specific commit
* @param {string} lastKnownCommitSha - The last known commit SHA
* @param {string} branchName - Name of the branch to check (default: 'main')
* @param {string} repo - Name of the repository (default: 'MegaRoast_discovery')
* @returns {Promise<Array>} Status indicating if an update is needed
*/
async checkForUpdates(lastKnownCommitSha, branchName = 'main', repo = 'MegaRoast_discovery') {
// Track this operation
const tracking = logger.trackOperation ?
logger.trackOperation(this.objType, 'checkForUpdates') :
{ end: () => {} };
try {
// Validate parameters
if (!lastKnownCommitSha) {
return this._createError(
'Missing required parameter: lastKnownCommitSha',
null,
400
);
}
// Get current branch status
const statusResult = await this.getBranchStatus(branchName, repo);
if (!statusResult[0]) {
return statusResult; // Return error from getBranchStatus
}
const currentCommitSha = statusResult[2].sha;
const updateNeeded = currentCommitSha !== lastKnownCommitSha;
// Create response with update status
return this._createSuccess(
updateNeeded ?
`Repository has been updated since commit ${lastKnownCommitSha.substring(0, 7)}` :
'Repository is up to date',
{
updateNeeded,
lastKnownCommitSha,
currentCommitSha,
repository: repo,
branch: branchName,
timestamp: new Date().toISOString()
}
);
} catch (error) {
return this._createError(
`Failed to check for updates: ${error.message}`,
error,
500
);
} finally {
tracking.end();
}
}
}