Files
factorio-clusterio/packages/lib/src/database.ts
T
024d7b427a Rename safeOutputFile to atomicOverwriteFile (#1167)
"safe" suggested the data being written survives a crash, it only
guarantees the old file is either kept or fully replaced. The new name
says what it does. No alias, plugin API breaks are allowed in minor
versions and the 2.1 migration guide lists the rename.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2026-11-01 23:14:31 +00:00

304 lines
8.4 KiB
TypeScript

/**
* Implementation of controller databases
*
* @module lib/database
* @author Hornwitser
*/
import fs from "node:fs/promises";
import * as libFileOps from "./file_ops.js";
import { basicType } from "./helpers.js";
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.atomicOverwriteFile(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.atomicOverwriteFile(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();
}
}