@clusterio/lib
Version: 
Shared library for Clusterio
304 lines (267 loc) • 8.61 kB
text/typescript
/**
 * Implementation of controller databases
 *
 * @module lib/database
 * @author Hornwitser
 */
import fs from "fs-extra";
import * as libFileOps from "./file_ops";
import { basicType } from "./helpers";
export type ItemCountWithQuality = { [quality: string]: number };
/**
 * Converts a Map with only string keys to a JavaScript object.
 *
 * @param map - the Map to convert to an object.
 * @return Object with the mapping's key-values as properties.
 * @throws {Error} if there are non-string keys in map.
 */
export function mapToObject<T>(map: Map<string, T>) {
	let obj: Record<string, T> = {};
	for (let [key, value] of map) {
		if (typeof key !== "string") {
			throw new Error(
				`Expected all keys to be string but got ${typeof key}`
			);
		}
		obj[key] = value;
	}
	return obj;
}
/**
 * Load JSON object file as a Map
 *
 * Loads the JSON file specified by path containing an object with
 * properties as key value pairs and converts it into a JavaScript Map.
 * Returns an empty Map if the file does not exist.
 *
 * @param filePath - The path to the JSON file to load.
 * @returns promise resolving to the loaded map
 * @throws {Error} If JSON file did not contain an object.
 * @throws {Error} If an error occured reading the file.
 */
export async function loadJsonAsMap(filePath: string): Promise<Map<string, unknown>> {
	try {
		let content = await fs.readFile(filePath);
		let parsed = JSON.parse(content.toString());
		if (basicType(parsed) !== "object") {
			throw new Error(`Expected object but got ${basicType(parsed)}`);
		}
		return new Map(Object.entries(parsed));
	} catch (err: any) {
		if (err.code === "ENOENT") {
			// File does not exist, return empty map
			return new Map();
		}
		throw err;
	}
}
/**
 * Save a Map as a JSON object file
 *
 * Save the content of a key-value Map with only string keys as a JSON file.
 * If the directory the file is to be saved into doesn't exist it will be
 * created.
 *
 * @param filePath - The path to the JSON file that will be saved.
 * @param map - Map with only string keys to save.
 * @throws {Error} if there are non-string keys in map.
 * @throws {Error} if an error occured writing to the file.
 */
export async function saveMapAsJson(filePath: string, map: Map<string, unknown>) {
	let obj = mapToObject(map);
	await libFileOps.safeOutputFile(filePath, JSON.stringify(obj, null, "\t"));
}
/**
 * Load JSON array file as a Map
 *
 * Loads the JSON file specified by path containing an array of objects that
 * each have an id property into a Map from the id to the object.
 *
 * @param filePath - The path to the JSON file to load.
 * @throws {Error} If JSON file did not contain an array.
 * @throws {Error} If there are objects in the array without an id property.
 * @throws {Error} If an error occured reading the file.
 */
export async function loadJsonArrayAsMap(filePath: string): Promise<Map<unknown, unknown>> {
	try {
		let content = await fs.readFile(filePath);
		let parsed = JSON.parse(content.toString());
		if (basicType(parsed) !== "array") {
			throw new Error(`Expected array but got ${basicType(parsed)}`);
		}
		let map = new Map();
		for (let element of parsed) {
			if (basicType(element) !== "object") {
				throw new Error("Expected all elements to be objects");
			}
			if (element.id === undefined) {
				throw new Error("Expected all elements to have an id property");
			}
			map.set(element.id, element);
		}
		return map;
	} catch (err: any) {
		if (err.code === "ENOENT") {
			// File does not exist, return empty map
			return new Map();
		}
		throw err;
	}
}
/**
 * Save a Map as a JSON array file
 *
 * Save the values of a Map with objects containing an id property that is
 * equal to the key the objects are stored at into a JSON file.
 *
 * @param filePath - The path to the JSON file that will be saved.
 * @param map -
 *     Map with objects containing an id property equal to the key they are
 *     stored under.
 * @throws {Error} if an error occured writing to the file.
 */
export async function saveMapAsJsonArray(filePath: string, map: Map<unknown, { id: unknown }>) {
	await libFileOps.safeOutputFile(filePath, JSON.stringify([...map.values()], null, "\t"));
}
function checkName(name: unknown): asserts name is string {
	if (typeof name !== "string") {
		throw new Error("name must be a string");
	}
}
function checkCount(count: unknown): asserts count is number {
	if (typeof count !== "number" || isNaN(count)) {
		throw new Error("count must be a number");
	}
}
function checkQuality(quality: unknown): asserts quality is string {
	if (typeof quality !== "string") {
		throw new Error("quality must be a string");
	}
}
/**
 * Item database
 *
 * Stores counts of items for the controller.  Items that have not been
 * stored in the database are treated as having a stored count of zero.  When
 * serialized the database discards entries with a zero counts, and when
 * deserialized the content is verified.
 */
export class ItemDatabase {
	private _items: Map<string, ItemCountWithQuality> = new Map();
	/**
	 * Create a new item database
	 *
	 * @param {object|undefined} serialized - An object from a previous call to
	 *     {@link module:libbase~ItemDatabase#serialize} to restore the
	 *     database from.  An empty database will be created if this parameter
	 *     is left undefined.
	 */
	constructor(serialized?: object) {
		// Verify the content of the serialized database
		if (serialized !== undefined) {
			for (let [name, qualities] of Object.entries(serialized)) {
				checkName(name);
				// Migration from pre-quality database format
				if (typeof qualities === "number") {
					qualities = { normal: qualities };
				}
				for (const count of Object.values(qualities)) {
					checkCount(count);
				}
				this._items.set(name, qualities);
			}
		}
	}
	/**
	 * Serialize item database
	 *
	 * Serialize the item database into a plain JavaScript object that can be
	 * turned inta a string with JSON.stringify().
	 *
	 * @returns Serialized representation of the database
	 */
	serialize() {
		let obj: Record<string, ItemCountWithQuality> = {};
		for (let [name, qualities] of this._items) {
			for (const quality of Object.getOwnPropertyNames(qualities)) {
				if (qualities[quality] !== 0) {
					if (!obj[name]) {
						obj[name] = {};
					}
					obj[name][quality] = qualities[quality];
				}
			}
		}
		return obj;
	}
	/**
	 * Approximate size of the items database.
	 *
	 * Does not account for entries being zero in the item database.
	 */
	get size() {
		return this._items.size;
	}
	/**
	 * Get the count of an item stored in the database
	 *
	 * Returns the count of a given item that has been stored in to the
	 * database.  If the item has not been previously stored into the database
	 * then 0 is returned.
	 *
	 * @param name - The name of the item to get the count of.
	 * @param quality - The quality of the item to get the count of.
	 * @returns The count of the item stored.
	 */
	getItemCount(name: string, quality: string): number {
		checkName(name);
		checkQuality(quality);
		if (!this._items.get(name)?.[quality]) {
			return 0;
		}
		return this._items.get(name)![quality];
	}
	/**
	 * Adds count of an item to the database
	 *
	 * Add count copies of the item specified by name to the item database.
	 *
	 * @param name - The name of the item to add.
	 * @param count - The count of item to remove.
	 * @param quality - The quality of the item to add.
	 */
	addItem(name: string, count: number, quality: string) {
		checkName(name);
		checkCount(count);
		checkQuality(quality);
		if (!this._items.has(name)) {
			this._items.set(name, { [quality]: count });
			return;
		}
		// Nullish coalescing operator is used to avoid NaN when the quality is not present
		let currentCount = this._items.get(name)![quality] ?? 0;
		this._items.get(name)![quality] = currentCount + count;
	};
	/**
	 * Removes count of an item from the database.
	 *
	 * Remove count copies of the item specified by name from the item database
	 * If count is greater than the currently stored count the stored item
	 * count will become negative.
	 *
	 * @param name - The name of the item to remove.
	 * @param count - The count of items to remove.
	 * @param quality - The quality of the item to remove.
	 */
	removeItem(name: string, count: number, quality: string) {
		checkCount(count);
		this.addItem(name, -count, quality);
	};
	/**
	 * Allow to iterate through the items.
	 * @returns array of items.
	 */
	getEntries(): IterableIterator<[string, ItemCountWithQuality]> {
		return this._items.entries();
	}
}