UNPKG

@decaf-ts/core

Version:

Core persistence module for the decaf framework

698 lines 30.7 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Adapter = void 0; const db_decorators_1 = require("@decaf-ts/db-decorators"); const decorator_validation_1 = require("@decaf-ts/decorator-validation"); const constants_1 = require("./constants.cjs"); const logging_1 = require("@decaf-ts/logging"); const ObserverHandler_1 = require("./ObserverHandler.cjs"); const Context_1 = require("./Context.cjs"); const decoration_1 = require("@decaf-ts/decoration"); const errors_1 = require("./errors.cjs"); const ContextualLoggedClass_1 = require("./../utils/ContextualLoggedClass.cjs"); const utils_1 = require("./../utils/utils.cjs"); const flavourResolver = decoration_1.Decoration["flavourResolver"].bind(decoration_1.Decoration); decoration_1.Decoration["flavourResolver"] = (obj) => { try { const result = flavourResolver(obj); if (result && result !== decoration_1.DefaultFlavour) return result; const targetCtor = typeof obj === "function" ? obj : obj?.constructor; const registeredFlavour = targetCtor && typeof decoration_1.Metadata["registeredFlavour"] === "function" ? decoration_1.Metadata.registeredFlavour(targetCtor) : undefined; if (registeredFlavour && registeredFlavour !== decoration_1.DefaultFlavour) return registeredFlavour; const currentFlavour = Adapter["_currentFlavour"]; if (currentFlavour) { const cachedAdapter = Adapter["_cache"]?.[currentFlavour]; if (cachedAdapter?.flavour) return cachedAdapter.flavour; return currentFlavour; } // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { return decoration_1.DefaultFlavour; } }; /** * @description Abstract Facade class for persistence adapters * @summary Provides the foundation for all database adapters in the persistence layer. This class * implements several interfaces to provide a consistent API for database operations, observer * pattern support, and error handling. It manages adapter registration, CRUD operations, and * observer notifications. * @template CONFIG - The underlying persistence driver config * @template QUERY - The query object type used by the adapter * @template FLAGS - The repository flags type * @template CONTEXT - The context type * @param {CONFIG} _config - The underlying persistence driver config * @param {string} flavour - The identifier for this adapter type * @param {string} [_alias] - Optional alternative name for this adapter * @class Adapter * @example * ```typescript * // Implementing a concrete adapter * class PostgresAdapter extends Adapter<pg.PoolConfig, pg.Query, PostgresFlags, PostgresContext> { * constructor(client: pg.PoolConfig) { * super(client, 'postgres'); * } * * async initialize() { * // Set up the adapter * await this.native.connect(); * } * * async create(tableName, id, model) { * // Implementation for creating records * const columns = Object.keys(model).join(', '); * const values = Object.values(model); * const placeholders = values.map((_, i) => `$${i+1}`).join(', '); * * const query = `INSERT INTO ${tableName} (${columns}) VALUES (${placeholders}) RETURNING *`; * const result = await this.native.query(query, values); * return result.rows[0]; * } * * // Other required method implementations... * } * * // Using the adapter * const pgClient = new pg.Client(connectionString); * const adapter = new PostgresAdapter(pgClient); * await adapter.initialize(); * * // Set as the default adapter * Adapter.setCurrent('postgres'); * * // Perform operations * const user = await adapter.create('users', 1, { name: 'John', email: 'john@example.com' }); * ``` * @mermaid * classDiagram * class Adapter { * +Y native * +string flavour * +string alias * +create(tableName, id, model) * +read(tableName, id) * +update(tableName, id, model) * +delete(tableName, id) * +observe(observer, filter) * +unObserve(observer) * +static current * +static get(flavour) * +static setCurrent(flavour) * } * * class RawExecutor { * +raw(query) * } * * class Observable { * +observe(observer, filter) * +unObserve(observer) * +updateObservers(table, event, id) * } * * class Observer { * +refresh(table, event, id) * } * * class ErrorParser { * +parseError(err) * } * * Adapter --|> RawExecutor * Adapter --|> Observable * Adapter --|> Observer * Adapter --|> ErrorParser */ class Adapter extends ContextualLoggedClass_1.ContextualLoggedClass { static { this._cache = {}; } /** * @description Gets the native persistence config * @summary Provides access to the underlying persistence driver config * @template CONF * @return {CONF} The native persistence driver config */ get config() { return this._config; } /** * @description Gets the adapter's alias or flavor name * @summary Returns the alias if set, otherwise returns the flavor name * @return {string} The adapter's identifier */ get alias() { return this._alias || this.flavour; } /** * @description Gets the repository constructor for this adapter * @summary Returns the constructor for creating repositories that work with this adapter * @template M - The model type * @return {Constructor<Repository<any, Adapter<CONF, CONN, QUERY, CONTEXT>>>} The repository constructor */ repository() { if (!Adapter._baseRepository) throw new db_decorators_1.InternalError(`This should be overridden when necessary. Otherwise it will be replaced lazily`); return Adapter._baseRepository; } async shutdownProxies(k) { if (!this.proxies) return; if (k && !(k in this.proxies)) throw new db_decorators_1.InternalError(`No proxy found for ${k}`); if (!k) { for (const key in this.proxies) { try { await this.proxies[key].shutdown(); } catch (e) { this.log.error(`Failed to shutdown proxied adapter ${key}: ${e}`); continue; } delete this.proxies[key]; } } else { try { await this.proxies[k].shutdown(); delete this.proxies[k]; } catch (e) { this.log.error(`Failed to shutdown proxied adapter ${k}: ${e}`); } } } /** * @description Shuts down the adapter * @summary Performs any necessary cleanup tasks, such as closing connections * When overriding this method, ensure to call the base method first * @return {Promise<void>} A promise that resolves when shutdown is complete */ async shutdown() { await this.shutdownProxies(); if (this.dispatch) await this.dispatch.close(); } /** * @description Creates a new adapter instance * @summary Initializes the adapter with the native driver and registers it in the adapter cache */ constructor(_config, flavour, _alias) { super(); this._config = _config; this.flavour = flavour; this._alias = _alias; /** * @description The context constructor for this adapter * @summary Reference to the context class constructor used by this adapter */ this.Context = (Context_1.Context); if (this.alias in Adapter._cache) throw new db_decorators_1.InternalError(`${this.alias} persistence adapter ${this._alias ? `(${this.flavour}) ` : ""} already registered`); Adapter._cache[this.alias] = this; this.log.info(`Created ${this.alias} persistence adapter ${this._alias ? `(${this.flavour}) ` : ""} persistence adapter`); if (!Adapter._currentFlavour) { this.log.verbose(`Defined ${this.alias} persistence adapter as current`); Adapter._currentFlavour = this.alias; } } /** * @description Creates a new dispatch instance * @summary Factory method that creates a dispatch instance for this adapter * @return {Dispatch} A new dispatch instance */ Dispatch() { return new Adapter._baseDispatch(); } /** * @description Creates a new observer handler * @summary Factory method that creates an observer handler for this adapter * @return {ObserverHandler} A new observer handler instance */ ObserverHandler() { return new ObserverHandler_1.ObserverHandler(); } /** * @description Checks if an attribute name is reserved * @summary Determines if a given attribute name is reserved and cannot be used as a column name * @param {string} attr - The attribute name to check * @return {boolean} True if the attribute is reserved, false otherwise */ isReserved(attr) { return !attr; } /** * @description Initializes the adapter * @summary Performs any necessary setup for the adapter, such as establishing connections * @param {...any[]} args - Initialization arguments * @return {Promise<void>} A promise that resolves when initialization is complete */ // eslint-disable-next-line @typescript-eslint/no-unused-vars async initialize(...args) { } /** * @description Creates a sequence generator * @summary Factory method that creates a sequence generator for generating sequential values * @param {SequenceOptions} options - Configuration options for the sequence * @return {Promise<Sequence>} A promise that resolves to a new sequence instance */ async Sequence(options) { return new Adapter._baseSequence(options, this); } /** * @description Creates repository flags for an operation * @summary Generates a set of flags that describe a database operation, combining default flags with overrides * @template F - The Repository Flags type * @template M - The model type * @param {OperationKeys} operation - The type of operation being performed * @param {Constructor<M>} model - The model constructor * @param {Partial<F>} flags - Custom flag overrides * @param {...any[]} args - Additional arguments * @return {Promise<F>} The complete set of flags */ async flags(operation, model, flags, // eslint-disable-next-line @typescript-eslint/no-unused-vars ...args) { let log = (flags.logger || logging_1.Logging.for(this.toString())); if (flags.correlationId) log = log.for({ correlationId: flags.correlationId }); return Object.assign({}, constants_1.DefaultAdapterFlags, flags, { affectedTables: (Array.isArray(model) ? model : [model]).map(decorator_validation_1.Model.tableName), writeOperation: operation !== db_decorators_1.OperationKeys.READ, timestamp: new Date(), operation: operation, ignoredValidationProperties: decoration_1.Metadata.validationExceptions(Array.isArray(model) && model[0] ? model[0] : model, operation), logger: log, }); } /** * @description Creates a context for a database operation * @summary Generates a context object that describes a database operation, used for tracking and auditing * @template F - The Repository flags type * @template M - The model type * @param {OperationKeys.CREATE|OperationKeys.READ|OperationKeys.UPDATE|OperationKeys.DELETE} operation - The type of operation * @param {Partial<F>} overrides - Custom flag overrides * @param {Constructor<M>} model - The model constructor * @param {...any[]} args - Additional arguments * @return {Promise<C>} A promise that resolves to the context object */ async context(operation, overrides, model, ...args) { const log = this.log.for(this.context); log.debug(`Creating new context for ${operation} operation on ${model ? (Array.isArray(model) ? model.map((m) => m.name) : model.name) : "no"} model with flag overrides: ${JSON.stringify(overrides)}`); const flags = await this.flags(operation, model, overrides, ...args); return new this.Context().accumulate(flags); } /** * @description Prepares a model for persistence * @summary Converts a model instance into a format suitable for database storage, * handling column mapping and separating transient properties * handling column mapping and separating transient properties * @template M - The model type * @param {M} model - The model instance to prepare * @param args - optional args for subclassing purposes * @return The prepared data */ prepare(model, ...args) { const { log } = this.logCtx(args, this.prepare); const split = model.segregate(); const result = Object.entries(split.model).reduce((accum, [key, val]) => { if (typeof val === "undefined") return accum; const mappedProp = decorator_validation_1.Model.columnName(model.constructor, key); if (this.isReserved(mappedProp)) throw new db_decorators_1.InternalError(`Property name ${mappedProp} is reserved`); accum[mappedProp] = val; return accum; }, {}); if (model[constants_1.PersistenceKeys.METADATA]) { // TODO movo to couchdb log.silly(`Passing along persistence metadata for ${model[constants_1.PersistenceKeys.METADATA]}`); Object.defineProperty(result, constants_1.PersistenceKeys.METADATA, { enumerable: false, writable: true, configurable: true, value: model[constants_1.PersistenceKeys.METADATA], }); } return { record: result, id: model[decorator_validation_1.Model.pk(model.constructor)], transient: split.transient, }; } /** * @description Converts database data back into a model instance * @summary Reconstructs a model instance from database data, handling column mapping * and reattaching transient properties * @template M - The model type * @param obj - The database record * @param {Constructor<M>} clazz - The model class or name * @param pk - The primary key property name * @param {string|number|bigint} id - The primary key value * @param [transient] - Transient properties to reattach * @param [args] - options args for subclassing purposes * @return {M} The reconstructed model instance */ revert(obj, clazz, id, transient, ...args) { const { log, ctx } = this.logCtx(args, this.revert); const ob = {}; const pk = decorator_validation_1.Model.pk(clazz); ob[pk] = id; const m = new clazz(ob); log.silly(`Rebuilding model ${m.constructor.name} id ${id}`); const metadata = obj[constants_1.PersistenceKeys.METADATA]; // TODO move to couchdb const result = Object.keys(m).reduce((accum, key) => { accum[key] = obj[decorator_validation_1.Model.columnName(clazz, key)]; return accum; }, m); if (ctx.get("rebuildWithTransient") && transient) { log.verbose(`re-adding transient properties: ${Object.keys(transient).join(", ")}`); Object.entries(transient).forEach(([key, val]) => { if (key in result) throw new db_decorators_1.InternalError(`Transient property ${key} already exists on model ${m.constructor.name}. should be impossible`); result[key] = val; }); } if (metadata) { // TODO move to couchdb log.silly(`Passing along ${this.flavour} persistence metadata for ${m.constructor.name} id ${id}: ${metadata}`); Object.defineProperty(result, constants_1.PersistenceKeys.METADATA, { enumerable: false, configurable: true, writable: true, value: metadata, }); } return result; } /** * @description Creates multiple records in the database * @summary Inserts multiple records with the given IDs and data into the specified table * @param {string} tableName - The name of the table to insert into * @param id - The identifiers for the new records * @param model - The data to insert for each record * @param {...any[]} args - Additional arguments specific to the adapter implementation * @return A promise that resolves to an array of created records */ async createAll(clazz, id, model, ...args) { if (id.length !== model.length) throw new db_decorators_1.InternalError("Ids and models must have the same length"); const { log, ctxArgs } = this.logCtx(args, this.createAll); const tableLabel = decorator_validation_1.Model.tableName(clazz); log.debug(`Creating ${id.length} entries ${tableLabel} table`); return (0, utils_1.promiseSequence)(id.map((i, count) => () => this.create(clazz, i, model[count], ...ctxArgs))); } /** * @description Retrieves multiple records from the database * @summary Fetches multiple records with the given IDs from the specified table * @param {string} tableName - The name of the table to read from * @param id - The identifiers of the records to retrieve * @param {...any[]} args - Additional arguments specific to the adapter implementation * @return A promise that resolves to an array of retrieved records */ async readAll(clazz, id, ...args) { const { log, ctxArgs } = this.logCtx(args, this.readAll); const tableName = decorator_validation_1.Model.tableName(clazz); log.debug(`Reading ${id.length} entries ${tableName} table`); return (0, utils_1.promiseSequence)(id.map((i) => () => this.read(clazz, i, ...ctxArgs))); } /** * @description Updates multiple records in the database * @summary Modifies multiple existing records with the given IDs in the specified table * @param {Constructor<M>} tableName - The name of the table to update * @param {string[]|number[]} id - The identifiers of the records to update * @param model - The new data for each record * @param {...any[]} args - Additional arguments specific to the adapter implementation * @return A promise that resolves to an array of updated records */ async updateAll(clazz, id, model, ...args) { if (id.length !== model.length) throw new db_decorators_1.InternalError("Ids and models must have the same length"); const { log, ctxArgs } = this.logCtx(args, this.updateAll); const tableLabel = decorator_validation_1.Model.tableName(clazz); log.debug(`Updating ${id.length} entries ${tableLabel} table`); return (0, utils_1.promiseSequence)(id.map((i, count) => () => this.update(clazz, i, model[count], ...ctxArgs))); } /** * @description Deletes multiple records from the database * @summary Removes multiple records with the given IDs from the specified table * @param {string} tableName - The name of the table to delete from * @param id - The identifiers of the records to delete * @param {...any[]} args - Additional arguments specific to the adapter implementation * @return A promise that resolves to an array of deleted records */ async deleteAll(tableName, id, ...args) { const { log, ctxArgs } = Adapter.logCtx(args, this.deleteAll); log.debug(`Deleting ${id.length} entries from ${tableName} table`); return (0, utils_1.promiseSequence)(id.map((i) => () => this.delete(tableName, i, ...ctxArgs))); } /** * @description Registers an observer for database events * @summary Adds an observer to be notified about database changes. The observer can optionally * provide a filter function to receive only specific events. * @param {Observer} observer - The observer to register * @param {ObserverFilter} [filter] - Optional filter function to determine which events the observer receives * @return {void} */ observe(observer, filter) { if (!this.observerHandler) Object.defineProperty(this, "observerHandler", { value: this.ObserverHandler(), writable: false, }); this.observerHandler.observe(observer, filter); const log = this.log.for(this.observe); log.verbose(`Registering new observer ${observer.toString()}`); if (!this.dispatch) { log.info(`Creating dispatch for ${this.alias}`); this.dispatch = this.Dispatch(); this.dispatch.observe(this); } } /** * @description Unregisters an observer * @summary Removes a previously registered observer so it no longer receives database event notifications * @param {Observer} observer - The observer to unregister * @return {void} */ unObserve(observer) { if (!this.observerHandler) throw new db_decorators_1.InternalError("ObserverHandler not initialized. Did you register any observables?"); this.observerHandler.unObserve(observer); this.log .for(this.unObserve) .verbose(`Observer ${observer.toString()} removed`); } /** * @description Notifies all observers about a database event * @summary Sends notifications to all registered observers about a change in the database, * filtering based on each observer's filter function * @param {string} table - The name of the table where the change occurred * @param {OperationKeys|BulkCrudOperationKeys|string} event - The type of operation that occurred * @param {EventIds} id - The identifier(s) of the affected record(s) * @param {...any[]} args - Additional arguments to pass to the observers * @return {Promise<void>} A promise that resolves when all observers have been notified */ async updateObservers(table, event, id, ...args) { if (!this.observerHandler) throw new db_decorators_1.InternalError("ObserverHandler not initialized. Did you register any observables?"); const { log, ctxArgs } = Adapter.logCtx(args, this.updateObservers); log.verbose(`Updating ${this.observerHandler.count()} observers for adapter ${this.alias}: Event: `); await this.observerHandler.updateObservers(table, event, id, ...ctxArgs); } /** * @description Refreshes data based on a database event * @summary Implementation of the Observer interface method that delegates to updateObservers * @param {string} table - The name of the table where the change occurred * @param {OperationKeys|BulkCrudOperationKeys|string} event - The type of operation that occurred * @param {EventIds} id - The identifier(s) of the affected record(s) * @param {...any[]} args - Additional arguments related to the event * @return {Promise<void>} A promise that resolves when the refresh is complete */ async refresh(table, event, id, ...args) { return this.updateObservers(table, event, id, ...args); } /** * @description Gets a string representation of the adapter * @summary Returns a human-readable string identifying this adapter * @return {string} A string representation of the adapter */ toString() { return `${this.flavour} adapter`; } /** * @description Gets the adapter flavor associated with a model * @summary Retrieves the adapter flavor that should be used for a specific model class * @template M - The model type * @param {Constructor<M>} model - The model constructor * @return {string} The adapter flavor name */ static flavourOf(model) { return decoration_1.Metadata.flavourOf(model); } static get currentFlavour() { if (!Adapter._currentFlavour) throw new db_decorators_1.InternalError(`No persistence flavour set. Please initialize your adapter`); return Adapter._currentFlavour; } /** * @description Gets the current default adapter * @summary Retrieves the adapter that is currently set as the default for operations * @return {Adapter<any, any, any, any>} The current adapter */ static get current() { return Adapter.get(this.currentFlavour); } /** * @description Gets an adapter by flavor * @summary Retrieves a registered adapter by its flavor name * @template CONF - The database driver config * @template CONN - The database driver instance * @template QUERY - The query type * @template CONTEXT - The context type * @param {string} flavour - The flavor name of the adapter to retrieve * @return {Adapter<CONF, CONN, QUERY, CONTEXT> | undefined} The adapter instance or undefined if not found */ static get(flavour) { if (!flavour) return Adapter.get(this._currentFlavour); if (flavour in this._cache) return this._cache[flavour]; throw new db_decorators_1.InternalError(`No Adapter registered under ${flavour}.`); } /** * @description Sets the current default adapter * @summary Changes which adapter is used as the default for operations * @param {string} flavour - The flavor name of the adapter to set as current * @return {void} */ static setCurrent(flavour) { this._currentFlavour = flavour; } /** * @description Gets all models associated with an adapter flavor * @summary Retrieves all model constructors that are configured to use a specific adapter flavor * @template M - The model type * @param {string} flavour - The adapter flavor to find models for * @return An array of model constructors */ static models(flavour) { try { return decoration_1.Metadata.flavouredAs(flavour).filter(decorator_validation_1.Model.isModel); } catch (e) { throw new db_decorators_1.InternalError(e); } } static decoration() { } static logCtx(args, method) { return super.logCtx(args, method); } get client() { if (!this._client) { this._client = this.getClient(); } return this._client; } // eslint-disable-next-line @typescript-eslint/no-unused-vars for(config, ...args) { if (!this.proxies) this.proxies = {}; const key = `${this.alias} - ${(0, decorator_validation_1.hashObj)(config)}`; if (key in this.proxies) return this.proxies[key]; let client; const proxy = new Proxy(this, { get: (target, p, receiver) => { if (p === "_config") { const originalConf = Reflect.get(target, p, receiver); return Object.assign({}, originalConf, config); } if (p === "_client") { return client; } return Reflect.get(target, p, receiver); }, set: (target, p, value, receiver) => { if (p === "_client") { client = value; return true; } return Reflect.set(target, p, value, receiver); }, }); this.proxies[key] = proxy; return proxy; } migrations() { return decoration_1.Metadata.migrationsFor(this); } async getQueryRunner() { return this; } async migrate(migrations = this.migrations(), ...args) { if (migrations instanceof Context_1.Context) { args = [migrations]; migrations = this.migrations(); } const { ctx } = Adapter.logCtx(args, this.migrate); const qr = await this.getQueryRunner(); for (const migration of migrations) { try { const m = new migration(); await m.up(qr, this, ctx); await m.down(qr, this, ctx); } catch (e) { throw new errors_1.MigrationError(e); } } } } exports.Adapter = Adapter; __decorate([ (0, logging_1.final)(), __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], Adapter.prototype, "shutdownProxies", null); __decorate([ (0, logging_1.final)(), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object, Object, Object]), __metadata("design:returntype", Promise) ], Adapter.prototype, "context", null); __decorate([ (0, logging_1.final)(), __metadata("design:type", Function), __metadata("design:paramtypes", [Object, Function]), __metadata("design:returntype", void 0) ], Adapter.prototype, "observe", null); __decorate([ (0, logging_1.final)(), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", void 0) ], Adapter.prototype, "unObserve", null); __decorate([ (0, logging_1.final)(), __metadata("design:type", Object), __metadata("design:paramtypes", []) ], Adapter.prototype, "client", null); //# sourceMappingURL=Adapter.js.map