UNPKG

@alwatr/nitrobase-engine

Version:

Nitrobase is a blazingly fast, lightweight database built on JSON. It stores data entirely in memory for lightning-quick access, while also providing a JSON file backup for persistence. You can easily serve your data over the web using our high-performanc

500 lines (456 loc) 17.1 kB
import {delay} from '@alwatr/delay'; import {exitHook} from '@alwatr/exit-hook'; import {getStoreId, getStorePath} from '@alwatr/nitrobase-helper'; import {CollectionReference, DocumentReference} from '@alwatr/nitrobase-reference'; import { StoreFileType, StoreFileExtension, Region, type StoreFileStat, type StoreFileContext, type CollectionContext, type DocumentContext, type StoreFileId, type CollectionItem, } from '@alwatr/nitrobase-types'; import {existsSync, readJson, resolve, unlink, writeJson} from '@alwatr/node-fs'; import {logger} from './logger.js'; import type { DictionaryReq, JsonObject, DictionaryOpt } from '@alwatr/type-helper'; DEV_MODE && logger.logFileModule?.('alwatr-nitrobase'); /** * AlwatrNitrobase configuration. */ export interface AlwatrNitrobaseConfig { /** * The root path of the storage. * This is where the AlwatrNitrobase will nitrobase its data. */ rootPath: string; /** * The save debounce timeout in milliseconds for minimal disk I/O usage. * This is used to limit the frequency of disk writes for performance reasons. * The recommended value is `40`. */ defaultChangeDebounce?: number; /** * If true, an error will be thrown when trying to read or write to a nitrobase file that is not initialized (new storage). * The default value is `false` but highly recommended to set it to `true` in production to prevent data loss. */ errorWhenNotInitialized?: boolean; } /** * AlwatrNitrobase engine. * * It provides methods to read, write, validate, and manage nitrobase files. * It also provides methods to interact with `documents` and `collections` in the nitrobase. */ export class AlwatrNitrobase { /** * The Alwatr Nitrobase version string. * * Use for nitrobase file format version for check compatibility. */ public static readonly version = __package_version__; /** * The root nitrobase file stat. */ private static readonly rootDbStat__: StoreFileStat = { name: '.nitrobase', region: Region.Secret, type: StoreFileType.Collection, extension: StoreFileExtension.Json, changeDebounce: 40, }; /** * `collectionReference` of all `storeFileStat`s. * This is the root nitrobase collection. */ private rootDb__; /** * Keep all loaded nitrobase file context loaded in memory. */ private cacheReferences__: DictionaryReq<DocumentReference | CollectionReference> = {}; /** * Constructs an AlwatrNitrobase instance with the provided configuration. * * @param config The configuration of the AlwatrNitrobase engine. * @example * ```typescript * const alwatrStore = new AlwatrNitrobase({ * rootPath: './db', * saveDebounce: 40, * }); * ``` */ constructor(public readonly config: AlwatrNitrobaseConfig) { this.storeChanged_ = this.storeChanged_.bind(this); logger.logMethodArgs?.('new', config); this.config.defaultChangeDebounce ??= 40; this.rootDb__ = this.loadRootDb__(); exitHook(this.exitHook__.bind(this)); } /** * Checks if a nitrobase file with the given ID exists. * * @param storeId - The ID of the nitrobase file to check. * @returns `true` if the nitrobase file exists, `false` otherwise. * @example * ```typescript * if (!alwatrStore.hasStore('user1/profile')) { * alwatrStore.defineDocument(...) * } * ``` */ public hasStore(storeId: StoreFileId): boolean { const id_ = getStoreId(storeId); const exists = this.rootDb__.hasItem(id_); logger.logMethodFull?.('hasStore', id_, exists); return exists; } /** * Defines a new document with the given configuration and initial data. * If a document with the same ID already exists, an error is thrown. * * @param stat nitrobase file stat * @param data initial data for the document * @template TDoc document data type * @example * ```typescript * await alwatrStore.newDocument<Order>( * { * name: 'profile', * region: Region.PerUser, * ownerId: 'user1', * }, * { * name: 'Ali', * email: 'ali@alwatr.io', * } * ); * ``` */ public newDocument<TDoc extends JsonObject = JsonObject>(stat: Omit<StoreFileStat, 'type'>, data: TDoc): void { logger.logMethodArgs?.('newDocument', stat); return this.newStoreFile__( { ...stat, type: StoreFileType.Document, }, data, ); } /** * Defines a new collection with the given configuration and initial data. * If a collection with the same ID already exists, an error is thrown. * * @param stat nitrobase file stat * @example * ```typescript * await alwatrStore.newCollection<Order>( * { * name: 'orders', * region: Region.PerUser, * ownerId: 'user1', * } * ); * ``` */ public newCollection(stat: Omit<StoreFileStat, 'type'>): void { logger.logMethodArgs?.('newCollection', stat); return this.newStoreFile__({ ...stat, type: StoreFileType.Collection, }); } /** * Defines a AlwatrNitrobaseFile with the given configuration and initial data. * * @param stat nitrobase file stat * @param data initial data for the document */ private newStoreFile__(stat: StoreFileStat, data?: DictionaryOpt<any>): void { logger.logMethodArgs?.('newStoreFile__', stat); (stat.changeDebounce as number | undefined) ??= this.config.defaultChangeDebounce; let fileStoreRef: DocumentReference | CollectionReference; if (stat.type === StoreFileType.Document) { if (data === undefined) { DEV_MODE && logger.accident('newStoreFile__', 'document_data_required', stat); throw new Error('document_data_required', {cause: stat}); } fileStoreRef = DocumentReference.newRefFromData(stat, data, this.storeChanged_); } else if (stat.type === StoreFileType.Collection) { fileStoreRef = CollectionReference.newRefFromData(stat, this.storeChanged_); } else { DEV_MODE && logger.accident('newStoreFile__', 'store_file_type_not_supported', stat); throw new Error('store_file_type_not_supported', {cause: stat}); } if (this.rootDb__.hasItem(fileStoreRef.id)) { DEV_MODE && logger.accident('newStoreFile__', 'store_file_already_defined', stat); throw new Error('store_file_already_defined', {cause: stat}); } this.rootDb__.addItem(fileStoreRef.id, stat); this.cacheReferences__[fileStoreRef.id] = fileStoreRef; // fileStoreRef.save(); this.storeChanged_(fileStoreRef); } /** * Open a document with the given id and create and return a DocumentReference. * If the document not exists or its not a document, an error is thrown. * * @template TDoc document data type * @param documentId document id {@link StoreFileId} * @returns document reference {@link DocumentReference} * @example * ```typescript * const userProfile = await alwatrStore.openDocument<User>({ * name: 'user1/profile', * region: Region.PerUser, * ownerId: 'user1', * }); * userProfile.update({name: 'ali'}); * ``` */ public async openDocument<TDoc extends JsonObject>(documentId: StoreFileId): Promise<DocumentReference<TDoc>> { const id = getStoreId(documentId); logger.logMethodArgs?.('openDocument', id); if (Object.hasOwn(this.cacheReferences__, id)) { const ref = this.cacheReferences__[id]; if (!(ref instanceof DocumentReference)) { DEV_MODE && logger.accident('openDocument', 'document_wrong_type', id); throw new Error('document_wrong_type', {cause: id}); } return this.cacheReferences__[id] as unknown as DocumentReference<TDoc>; } if (!this.rootDb__.hasItem(id)) { DEV_MODE && logger.accident('openDocument', 'document_not_found', id); throw new Error('document_not_found', {cause: id}); } const storeStat = this.rootDb__.getItemData(id); if (storeStat.type != StoreFileType.Document) { DEV_MODE && logger.accident('openDocument', 'document_wrong_type', id); throw new Error('document_wrong_type', {cause: id}); } const context = await this.readContext__<DocumentContext<TDoc>>(storeStat); const docRef = DocumentReference.newRefFromContext(context, this.storeChanged_); this.cacheReferences__[id] = docRef as unknown as DocumentReference; return docRef; } /** * Open a collection with the given id and create and return a CollectionReference. * If the collection not exists or its not a collection, an error is thrown. * * @template TItem collection item data type * @param collectionId collection id {@link StoreFileId} * @returns collection reference {@link CollectionReference} * @example * ```typescript * const orders = await alwatrStore.openCollection<Order>({ * name: 'orders', * region: Region.PerUser, * ownerId: 'user1', * }); * orders.append({name: 'order 1'}); * ``` */ public async openCollection<TItem extends JsonObject>(collectionId: StoreFileId): Promise<CollectionReference<TItem>> { const id = getStoreId(collectionId); logger.logMethodArgs?.('openCollection', id); // try to get from cache if (Object.hasOwn(this.cacheReferences__, id)) { const ref = this.cacheReferences__[id]; if (!(ref instanceof CollectionReference)) { DEV_MODE && logger.accident('openCollection', 'collection_wrong_type', id); throw new Error('collection_wrong_type', {cause: id}); } return this.cacheReferences__[id] as unknown as CollectionReference<TItem>; } // load and create new collection reference if (!this.rootDb__.hasItem(id)) { DEV_MODE && logger.accident('openCollection', 'collection_not_found', id); throw new Error('collection_not_found', {cause: id}); } const storeStat = this.rootDb__.getItemData(id); if (storeStat.type != StoreFileType.Collection) { DEV_MODE && logger.accident('openCollection', 'collection_wrong_type', id); throw new Error('collection_not_found', {cause: id}); } const context = await this.readContext__<CollectionContext<TItem>>(storeStat); const colRef = CollectionReference.newRefFromContext(context, this.storeChanged_); this.cacheReferences__[id] = colRef as unknown as CollectionReference; return colRef; } /** * Unloads the nitrobase file with the given id from memory. * * @param storeId The unique identifier of the nitrobase file. {@link StoreFileId} * @example * ```typescript * alwatrStore.unloadStore({name: 'user-list', region: Region.Secret}); * alwatrStore.hasStore({name: 'user-list', region: Region.Secret}); // true * ``` */ public unloadStore(storeId: StoreFileId): void { const id_ = getStoreId(storeId); logger.logMethodArgs?.('unloadStore', id_); const ref = this.cacheReferences__[id_]; if (ref === undefined) return; if (ref.hasUnprocessedChanges_ === true) { ref.updateDelayed_ = false; this.storeChanged_(ref); } delete this.cacheReferences__[id_]; } /** * Remove document or collection from nitrobase and delete the file from disk. * If the file is not found, an error is thrown. * If the file is not unloaded, it will be unloaded first. * You don't need to await this method to complete unless you want to make sure the file is deleted on disk. * * @param storeId The ID of the file to delete. {@link StoreFileId} * @returns A Promise that resolves when the file is deleted. * @example * ```typescript * alwatrStore.removeStore({name: 'user-list', region: Region.Secret}); * alwatrStore.hasStore({name: 'user-list', region: Region.Secret}); // false * ``` */ public async removeStore(storeId: StoreFileId): Promise<void> { const id_ = getStoreId(storeId); logger.logMethodArgs?.('removeStore', id_); if (!this.rootDb__.hasItem(id_)) { DEV_MODE && logger.accident('removeStore', 'document_not_found', id_); throw new Error('document_not_found', {cause: id_}); } const ref = this.cacheReferences__[id_]; if (ref !== undefined) { // direct unload to prevent save ref.freeze = true; ref.updateDelayed_ = false; ref.hasUnprocessedChanges_ = false; delete this.cacheReferences__[id_]; // unload } const path = getStorePath(this.rootDb__.getItemData(id_)); this.rootDb__.removeItem(id_); await delay.by(0); try { await unlink(resolve(this.config.rootPath, path)); } catch (error) { logger.error('removeStore', 'remove_file_failed', error, {id: storeId, path}); } } /** * Saves all changes in the nitrobase. * * @returns A Promise that resolves when all changes are saved. * @example * ```typescript * await alwatrStore.saveAll(); * ``` */ public async saveAll(): Promise<void> { logger.logMethod?.('saveAll'); for (const ref of Object.values(this.cacheReferences__)) { if (ref.hasUnprocessedChanges_ === true && ref.freeze !== true) { ref.updateDelayed_ = false; await this.storeChanged_(ref); } } } /** * Reads the context from a given path or StoreFileStat object. * * @param path The path or StoreFileStat object from which to read the context. * @returns A promise that resolves to the context object. */ private async readContext__<T extends StoreFileContext>(path: string | StoreFileStat): Promise<T> { if (typeof path !== 'string') path = getStorePath(path); logger.logMethodArgs?.('readContext__', path); logger.time?.(`readContext__time(${path})`); const context = (await readJson(resolve(this.config.rootPath, path))) as T; logger.timeEnd?.(`readContext__time(${path})`); return context; } /** * Writes the context to the specified path. * * @template T The type of the context. * @param path The path where the context will be written. * @param context The context to be written. * @param sync Indicates whether the write operation should be synchronous. * @returns A promise that resolves when the write operation is complete. */ private writeContext__<T extends StoreFileContext>(path: string | StoreFileStat, context: T): Promise<void> { if (typeof path !== 'string') path = getStorePath(path); logger.logMethodArgs?.('writeContext__', path); return writeJson(resolve(this.config.rootPath, path), context); } /** * Write nitrobase file context. * * @param from nitrobase file reference * @returns A promise that resolves when the write operation is complete. */ protected async storeChanged_<T extends JsonObject>(from: DocumentReference<T> | CollectionReference<T>): Promise<void> { logger.logMethodArgs?.('storeChanged__', from.id); const rev = from.getStoreMeta().rev; try { await this.writeContext__(from.path, from.getFullContext_()); if (rev === from.getStoreMeta().rev) { // Context not changed during saving from.hasUnprocessedChanges_ = false; } } catch (error) { logger.error('storeChanged__', 'write_context_failed', {id: from.id, error}); } } /** * Load storeFilesCollection or create new one. */ private loadRootDb__(): CollectionReference<StoreFileStat> { logger.logMethod?.('loadRootDb__'); const fullPath = resolve(this.config.rootPath, getStorePath(AlwatrNitrobase.rootDbStat__)); if (!existsSync(fullPath)) { if (this.config.errorWhenNotInitialized === true) { throw new Error('store_not_found', {cause: 'Nitrobase not initialized'}); } logger.banner('Initialize new alwatr-nitrobase'); return CollectionReference.newRefFromData(AlwatrNitrobase.rootDbStat__, this.storeChanged_); } // else const context = readJson<CollectionContext<StoreFileStat>>(fullPath, true); return CollectionReference.newRefFromContext(context, this.storeChanged_, 'root-db'); } /** * Save all nitrobase files. */ private exitHook__(): void { logger.logMethod?.('exitHook__'); for (const ref of Object.values(this.cacheReferences__)) { logger.logProperty?.(`StoreFile.${ref.id}.hasUnprocessedChanges`, ref.hasUnprocessedChanges_); if (ref.hasUnprocessedChanges_ === true && ref.freeze !== true) { logger.incident?.('exitHook__', 'rescue_unsaved_context', {id: ref.id}); writeJson(resolve(this.config.rootPath, ref.path), ref.getFullContext_(), true); ref.hasUnprocessedChanges_ = false; } } } /** * Get all nitrobase files. * * @returns all nitrobase files. * @example * ```typescript * const storeList = alwatrStore.getStoreList(); * for (const nitrobase of storeList) { * console.log(nitrobase.meta.id, nitrobase.data); * } */ public getStoreList(): CollectionItem<Omit<StoreFileStat, 'schemaVer'>>[] { logger.logMethod?.('getStoreList'); return this.rootDb__.values(); } }