import { deserializeFromBinary, serializeToBinary } from '@og/binary-struct'; import { VirtualMethodNotImplementedError } from '@og/error-utils'; import { pluralize } from 'inflection'; import fs from 'node:fs/promises'; import path from 'node:path'; /** * @abstract * @class * @classdesc Represents a key-value storage interface. */ export class BaseKeyValueStorage { /** * Sets a value for the given class and primary key. * @param {string} cls - The class identifier. * @param {string} pk - The primary key. * @param {ArrayBuffer} val - The value to set. * @abstruct * @async */ async set(cls, pk, val) { throw new VirtualMethodNotImplementedError(); } /** * Retrieves the value associated with the given class and primary key. * @param {string} cls - The class identifier. * @param {string} pk - The primary key. * @abstruct * @async * @returns {Promise} - The value associated with the class and primary key, or null if not found. */ async get(cls, pk) { throw new VirtualMethodNotImplementedError(); } /** * Checks whether a value exists for the given class and primary key. * @param {string} cls - The class identifier. * @param {string} pk - The primary key. * @abstruct * @async * @returns {Promise} - True if the value exists, otherwise false. */ async has(cls, pk) { throw new VirtualMethodNotImplementedError(); } /** * Deletes the value associated with the given class and primary key. * @param {string} cls - The class identifier. * @param {string} pk - The primary key. * @abstruct * @async */ async destroy(cls, pk) { throw new VirtualMethodNotImplementedError(); } /** * Initializes or updates the class. * @param {string} cls - The class identifier. * @abstruct * @async */ async touchClass(cls) { } } /** * Represents a key-value storage implementation using the file system. * @class * @extends {BaseKeyValueStorage} */ export class FileSystemKeyValueStorage extends BaseKeyValueStorage { /** * Creates an instance of FileSystemKeyValueStorage. * @param {string} root - The root directory where key-value data will be stored. */ constructor(root) { super(); /** @private @type {string} */ this.root = root; } /** * Sets a value for the given class and primary key. * @param {string} cls - The class identifier. * @param {string} pk - The primary key. * @param {ArrayBuffer} val - The value to set. * @abstruct */ async set(cls, pk, val) { const filePath = this._getFilePath(cls, pk); await fs.writeFile(filePath, Buffer.from(val)); } /** * Retrieves the value associated with the given class and primary key. * @param {string} cls - The class identifier. * @param {string} pk - The primary key. * @abstruct * @returns {Promise} - The value associated with the class and primary key, or null if not found. */ async get(cls, pk) { const filepath = this._getFilePath(cls, pk); try { const data = await fs.readFile(filepath); return data.buffer; } catch (error) { if (error.code === 'ENOENT') { // File not found return null; } else { throw error; } } } /** * Checks whether a value exists for the given class and primary key. * @param {string} cls - The class identifier. * @param {string} pk - The primary key. * @abstruct * @returns {Promise} - True if the value exists, otherwise false. */ async has(cls, pk) { const filepath = this._getFilePath(cls, pk); try { await fs.access(filepath); return true; } catch { return false; } } /** * Deletes the value associated with the given class and primary key. * @param {string} cls - The class identifier. * @param {string} pk - The primary key. * @abstruct */ async destroy(cls, pk) { const filepath = this._getFilePath(cls, pk); await fs.unlink(filepath); } /** * Initializes or updates the class by creating a folder for it if it doesn't exist. * @param {string} cls - The class identifier. * @abstruct */ async touchClass(cls) { const cls_dir = path.join(this.root, cls); await fs.mkdir(cls_dir, { recursive: true }); } /** * Constructs the file path for a given class and primary key. * @private * @param {string} cls - The class identifier. * @param {string} pk - The primary key. * @returns {string} - The file path. */ _getFilePath(cls, pk) { const cls_dir = path.join(this.root, cls); return path.join(cls_dir, pk); } } /** * Abstract base class for all models. * @abstract * @class */ export class BaseModel { /** * Gets the primary key of the model. * @abstract * @returns {string|null} The primary key value or null if not applicable. */ get pk() { throw new VirtualMethodNotImplementedError(); } /** * Asynchronously saves the current model instance to the key-value storage. * @throws {Error} - Throws an error if the primary key is not set. * @returns {Promise} * @abstract */ async save() { throw new VirtualMethodNotImplementedError(); } /** * Asynchronously destroys the current model instance in the key-value storage. * @throws {Error} - Throws an error if the primary key is not set. * @returns {Promise} * @abstract */ async destroy() { throw new VirtualMethodNotImplementedError(); } /** * Asynchronously checks if the current model instance can be created in the key-value storage. * @returns {Promise} - True if the model can be created, false otherwise. * @abstract */ async canCreate() { throw new VirtualMethodNotImplementedError(); } /** * Asynchronously loads a model instance from the key-value storage based on its primary key. * @param {string} pk - The primary key of the model to load. * @returns {Promise<*>} The loaded model instance or null if not found. * @abstract */ static async load(pk) { throw new VirtualMethodNotImplementedError(); } } /** * A mixin function that creates a model class with basic CRUD operations and storage methods. * * @param {string} name - The name of the model. * @param {BaseKeyValueStorage} kv - The key-value storage for data persistence. * @returns {Function} - The generated model class. */ export function Model(name, kv) { const cls = pluralize(name); /** * @class * @extends BaseModel */ return class extends BaseModel { /** * Asynchronously saves the current model instance to the key-value storage. * @throws {Error} - Throws an error if the primary key is not set. * @returns {Promise} */ async save() { const pk = this.pk; if (pk == null) { throw new PrimaryKeyNotSetError(); } const buf = serializeToBinary(this, this.constructor); await kv.set(cls, pk, buf); } /** * Asynchronously destroys the current model instance in the key-value storage. * @throws {Error} - Throws an error if the primary key is not set. * @returns {Promise} */ async destroy() { const pk = this.pk; if (pk == null) { throw new PrimaryKeyNotSetError(); } await kv.destroy(cls, pk); } /** * Asynchronously checks if the current model instance can be created in the key-value storage. * @returns {Promise} - True if the model can be created, false otherwise. */ async canCreate() { const pk = this.pk; if (pk == null) { return false; } return !(await kv.has(cls, pk)); } /** * Asynchronously loads a model instance from the key-value storage based on its primary key. * @param {string} pk - The primary key of the model to load. * @returns {Promise<*>} - The loaded model instance or null if not found. */ static async load(pk) { const buf = await kv.get(cls, pk); if (buf == null) { return null; } return deserializeFromBinary(new DataView(buf), this); } /** * Initializes the key-value storage for the current class. */ static { kv.touchClass(cls); } }; } /** * Represents an error that is thrown when the primary key of an object is not set. * @class * @extends {Error} */ class PrimaryKeyNotSetError extends Error { /** * Constructs a new PrimaryKeyNotSetError instance with a default error message. * @constructor */ constructor() { super('pk() returns null'); } };