SystemState.js

const debug = require('debug')('modularity');
const Module = require('./Module');
const Semaphore = require('./Semaphore');

/**
 * A container for modules. Holds system state. It's a module itself.
 */
class SystemState extends Module {
	constructor() {
		super();
		this.knownClasses = {};
		this.setupQueue = [];
		this.createdNonExclusiveObjects = [];

		this.semaphore = new Semaphore('SystemState');
	}

	/**
	 * Add a module to the known classes library.
	 * @param classObject The class
	 * @param [overrideName] Class name override if encountering issues with transpiled solutions.
	 */
	addModuleClass(classObject, overrideName = null) {
		if (!classObject) {
			throw Error(
				`The class object provided is falsy. Make sure to pass a valid class or that the variable ` +
					`does not depend on a circular reference and cannot be transipled correctly.`
			);
		}

		const name = overrideName || classObject.name;

		if (!name) {
			throw Error(`Could not infer class name from provided object. Please provide an alias manually.`);
		}

		if (this.knownClasses[name]) {
			throw Error(`Tried to addModuleClass for an already known class or alias (${name}).`);
		}
		this.knownClasses[name] = classObject;
	}

	/**
	 * Prepare module instances but don't initialize them.
	 */
	bootstrap(requirements) {
		debug('Bootstrapping system');
		return this.semaphore.oneAtATimeSync(() => {
			const result = {};
			const setupQueue = [];
			const ops = Object.keys(requirements).map(prop => ['resolve', requirements[prop], prop]);

			while (ops.length > 0) {
				const [op, ...args] = ops.shift();

				switch (op) {
					case 'resolve':
						debug('Resolving bootstrap subject ' + args[1]);
						const instance = this.constructModule(args[0]);

						if (!instance.moduleIsExclusive()) {
							this.createdNonExclusiveObjects.push(instance);
						}

						result[args[1]] = instance;
						setupQueue.push(instance);
						ops.push(['inject', instance]);
						break;
					case 'inject':
						// TODO: Ability to request unique?
						const dependencies = [];
						let requestFnLegal = true;
						const requestFn = nameOrClassObject => {
							if (!requestFnLegal) {
								throw Error(`Invalid injection request call. Bootstrap was finished.`);
							}
							// Must return an instance
							// Check if already have an instance
							const ResolvedClass = this.resolveClass(nameOrClassObject);

							debug(`Requested an instance of ${ResolvedClass.name}`);
							const existing = this.createdNonExclusiveObjects.find(obj => obj instanceof ResolvedClass);

							if (existing && !existing.moduleIsExclusive()) {
								debug(`Requested instance is already built and non-exclusive`);
								dependencies.push(existing);
								return existing;
							}

							// Build
							const created = this.constructModule(nameOrClassObject);

							if (created.moduleIsExclusive()) {
								debug(`Created instance of ${ResolvedClass.name} is exclusive and will not be reused`);
							} else {
								this.createdNonExclusiveObjects.push(created);
							}
							setupQueue.push(created);

							ops.push(['inject', created]);
							dependencies.push(created);
							return created;
						};

						args[0].modulePerformInjection(requestFn);
						requestFnLegal = false;
						break;
					/* istanbul ignore next */
					default:
						throw Error('Internal error - unknown op: ' + op);
				}
			}
			debug('Done Bootstrapping, ready to set up');
			this.setupQueue = [...this.setupQueue, ...setupQueue];
			this.invertSetupModulesList();
			if (Array.isArray(requirements)) {
				return Object.values(result);
			}
			return result;
		});
	}

	constructModule(nameOrClassObject) {
		const ResolvedClass = this.resolveClass(nameOrClassObject);

		debug(`Attempting construction of class ${ResolvedClass.name}`);
		const instance = new ResolvedClass();

		instance.setSystemStateReference(this);
		return instance;
	}

	/**
	 * Return the class of a given name. If the argument is already a class then return it.
	 * @param {*} nameOrClassObject
	 */
	resolveClass(nameOrClassObject) {
		let normalizedName = nameOrClassObject;

		if (typeof nameOrClassObject === 'function') {
			normalizedName = nameOrClassObject.name;
		}

		if (typeof normalizedName === 'string') {
			const translated = this.knownClasses[normalizedName];

			if (translated) {
				return translated;
			}
			throw Error('Unable to resolve class ' + normalizedName);
		} else {
			throw Error('Unable to resolve class of a name that is not a string');
		}
	}

	/**
	 * Setup bootstrapped modules.
	 */
	async setup() {
		debug('Setup starting');
		return this.semaphore.oneAtATime(async () => {
			let leftovers = [...this.setupQueue];
			const newSetupQueue = [];
			const postSetupQueue = [];

			while (leftovers.length > 0) {
				const newLeftovers = [];

				for (const mod of leftovers) {
					debug('Setup of module ' + mod.constructor.name);
					try {
						mod.assertDependenciesSetup();
					} catch (err) {
						newLeftovers.push(mod);
						continue;
					}
					if (!mod.moduleWasSetUp()) {
						await mod.setup();
						postSetupQueue.push(mod);
					}
					newSetupQueue.push(mod);
					if (!mod.moduleWasSetUp()) {
						throw Error(
							`Module ${mod.constructor.name} does not properly implement the setup method. ` +
								`Make sure you're calling super.setup()`
						);
					}
				}

				if (newLeftovers.length >= leftovers.length) {
					const errs = [];

					for (const mod of newLeftovers) {
						try {
							mod.assertDependenciesSetup();
						} catch (err) {
							errs.push(err);
						}
					}
					throw Error(
						`Failed to initialize ${newLeftovers.length} modules. The error messages were:\n${errs.join(
							'\n'
						)}`
					);
				}

				leftovers = newLeftovers;
			}

			for (const mod of postSetupQueue) {
				debug(`postSetup of module ` + mod.constructor.name);
				await mod.postSetup();
			}

			this.setupQueue = newSetupQueue;
		});
	}

	/**
	 * Teardown all modules. Cleans up references.
	 * Must call bootstrap to use setup again.
	 */
	async teardown() {
		return this.semaphore.oneAtATime(async () => {
			this.invertSetupModulesList();
			for (const mod of this.setupQueue) {
				debug('Teardown of module ' + mod.constructor.name);
				await mod.teardown();
				if (mod.moduleWasSetUp()) {
					throw Error(
						`Invalid teardown implementation for module ${mod.constructor.name}. ` +
							`Make sure super.teardown is called.`
					);
				}
			}
			this.setupQueue = [];
			this.createdNonExclusiveObjects = [];
		});
	}

	invertSetupModulesList() {
		this.setupQueue = this.setupQueue.map((_, i, self) => self[self.length - i - 1]); // Invert
	}

	getModulesList() {
		return [...this.setupQueue];
	}
}

module.exports = SystemState;