Source

src/common/BaseRepository.js

"use strict";

/**
 * @module Core Infrastructure
 * @fileoverview BaseRepository - Data Access Layer for SOA architecture.
 *
 * The Repository layer sits between Services and the Database, providing:
 * - CRUD operations with consistent patterns
 * - Entity wrapping (database rows → immutable Entity instances)
 * - Pagination (offset-based, cursor TBD)
 * - Query builder access for custom queries
 * - Transaction support
 * - Hook system for cross-cutting concerns
 * - Immutability enforcement (frozen entities)
 *
 * **Architecture:**
 * ```
 * Service Layer (business logic)
 *      ↓
 * Repository Layer (data access) ← YOU ARE HERE
 *      ↓
 * Objection.js ORM (query building)
 *      ↓
 * PostgreSQL Database
 * ```
 *
 * **Design Pattern:**
 * All repositories extend BaseRepository and provide domain-specific queries:
 *
 * ```javascript
 * class IconRepository extends BaseRepository {
 *   constructor(opts = {}) {
 *     super({
 *       modelName: 'IconModel',
 *       entityClass: IconEntity,
 *       ...opts
 *     });
 *   }
 *
 *   // Custom query methods
 *   async findBySetId(setId) {
 *     return this.model.query().where('set_id', setId);
 *   }
 * }
 * ```
 *
 * **Key Features:**
 * - **Entity Wrapping**: Automatic conversion of DB rows to Entity instances
 * - **Immutability**: All returned entities are frozen (Object.freeze)
 * - **Hooks**: afterFind, afterList for post-processing
 * - **Transactions**: Pass `trx` option to any method
 * - **Related Data**: withRelations() for eager loading
 *
 * @see {@link BaseEntity} For entity layer documentation
 * @see {@link BaseService} For service layer documentation
 */

/**
 * Recursively freeze an object and all nested objects/arrays.
 *
 * Prevents accidental mutations of entities after retrieval from database.
 * Uses WeakSet to handle circular references safely.
 *
 * @private
 * @param {*} value - Value to freeze (object, array, or primitive)
 * @param {WeakSet} [seen=new WeakSet()] - Tracks visited objects to avoid infinite loops
 * @returns {*} Frozen value
 */
const deepFreeze = (value, seen = new WeakSet()) => {
    if (value == null || typeof value !== 'object' || seen.has(value)) return value;

    seen.add(value);

    if (Array.isArray(value)) {
        value.forEach((item) => deepFreeze(item, seen));
    } else {
        Object.getOwnPropertyNames(value).forEach((key) => {
            const v = value[key];
            if (v && typeof v === 'object') deepFreeze(v, seen);
        });
    }

    return Object.freeze(value);
};

/**
 * Base repository class providing data access patterns for all domain repositories.
 *
 * Handles the translation between database rows (via Objection.js) and immutable
 * Entity instances. All domain repositories (IconRepository, UserRepository, etc.)
 * extend this class and inherit these CRUD operations.
 *
 * **Responsibilities:**
 * - Execute database queries via Objection.js models
 * - Convert query results to Entity instances
 * - Enforce immutability (freeze entities)
 * - Support transactions
 * - Provide consistent CRUD interface
 *
 * **Does NOT:**
 * - Contain business logic (use Services)
 * - Handle caching (use Services with CacheableService mixin)
 * - Emit events (use Services)
 * - Enforce access control (use Services with AccessControl)
 *
 * @class BaseRepository
 *
 * @example
 * // Define domain repository
 * class IconRepository extends BaseRepository {
 *   constructor(opts = {}) {
 *     super({
 *       modelName: 'IconModel',
 *       entityClass: IconEntity,
 *       hooks: {
 *         afterFind: async (icon) => {
 *           // Post-process icon if needed
 *           return icon;
 *         }
 *       },
 *       ...opts
 *     });
 *   }
 *
 *   // Custom query
 *   async findActiveBySetId(setId) {
 *     const rows = await this.model.query()
 *       .where({ set_id: setId, is_active: true })
 *       .orderBy('name');
 *     return this.wrapEntity(rows, this.entityClass);
 *   }
 * }
 *
 * @example
 * // Use in service
 * const repository = new IconRepository();
 * const icon = await repository.findById(123);
 * console.log(icon instanceof IconEntity); // true
 * console.log(Object.isFrozen(icon)); // true
 */
class BaseRepository {
    /**
     * Construct repository with model and entity bindings.
     *
     * @param {Object} options - Repository configuration
     * @param {Object} [options.DB] - Database instance (default: require('@vectoricons.net/db'))
     * @param {string} options.modelName - Name of Objection model in DB instance (e.g., 'IconModel')
     * @param {Function} options.entityClass - Entity class to wrap results (e.g., IconEntity)
     * @param {Object} [options.hooks={}] - Lifecycle hooks (afterFind, afterList)
     * @param {Function} [options.hooks.afterFind] - Called after findById/findOne
     * @param {Function} [options.hooks.afterList] - Called after findAll/paginate
     * @param {boolean} [options.freezeEntities=true] - Whether to freeze returned entities
     * @param {boolean} [options.deepFreezeEntities=true] - Whether to deep-freeze (recursive)
     *
     * @throws {Error} If DB instance is missing
     * @throws {Error} If modelName is missing
     * @throws {Error} If model doesn't exist in DB instance
     * @throws {Error} If entityClass is missing
     *
     * @example
     * const repo = new IconRepository({
     *   modelName: 'IconModel',
     *   entityClass: IconEntity,
     *   hooks: {
     *     afterList: async (icons) => {
     *       console.log(`Loaded ${icons.length} icons`);
     *       return icons;
     *     }
     *   }
     * });
     */
    constructor({
        DB = require('@vectoricons.net/db'),
        modelName,
        entityClass = this.entityClass,
        hooks = {},
        freezeEntities = true,
        deepFreezeEntities = true,
    } = {}) {
        if (!DB) {
            throw new Error('BaseRepository requires a DB instance');
        }

        if (!modelName) {
            throw new Error('BaseRepository requires a model name');
        }

        if (!DB[modelName]) {
            throw new Error('BaseRepository requires a model (Objection class)');
        }

        if (!entityClass) {
            throw new Error('BaseRepository requires an entity class');
        }

        this.DB = DB;
        this.modelName = modelName;
        this.model = DB[modelName];
        this.entityClass = entityClass;
        this.hooks = hooks;

        this.freezeEntities = freezeEntities;
        this.deepFreezeEntities = deepFreezeEntities;
    }

    /**
     * Applies a hook to the given value
     * @param {String} name - The name of the hook to apply
     * @param {Object|Array} value - The value to apply the hook to
     * @returns {Promise<Object|Array>} - The value after applying the hook
     */
    async applyHook(name, value) {
        const hook = this.hooks?.[name];
        if (!hook) return value;

        if (Array.isArray(value)) {
            if (name === 'afterList') {
                const result = await hook(value);
                return result ?? value;
            }
            const mapped = await Promise.all(value.map(item => hook(item)));
            return mapped;
        }

        const result = await hook(value);
        return result ?? value;
    }

    // Finalize: optional hook + freezing policy
    async finalize(value, hookName = null) {
        const hooked = hookName ? await this.applyHook(hookName, value) : value;

        if (!this.freezeEntities) return hooked;

        if (!this.deepFreezeEntities) {
            return Array.isArray(hooked)
                ? Object.freeze(hooked.slice())
                : Object.freeze(hooked);
        }

        return deepFreeze(hooked);
    }

    /**
     * Convert database rows to Entity instances.
     *
     * This is the core method that transforms plain database objects (from Objection.js)
     * into immutable, frozen Entity instances. Handles both single objects and arrays.
     *
     * **Entity Wrapping Process:**
     * 1. Convert Objection model instance to plain object (via toJSON if available)
     * 2. Pass to Entity constructor
     * 3. Entity filters hidden fields, materializes relations
     * 4. Returns frozen, immutable Entity instance
     *
     * @param {Object|Array<Object>} result - Database row(s) from Objection query
     * @param {Function} entityClass - Entity class to instantiate (e.g., IconEntity)
     * @param {Object} [entityOptions={}] - Options passed to Entity constructor
     * @param {boolean} [entityOptions.includeHiddenFields] - Include hidden fields
     * @returns {Object|Array<Object>} Entity instance(s)
     *
     * @example
     * // Single entity
     * const row = await IconModel.query().findById(1);
     * const icon = this.wrapEntity(row, IconEntity);
     * console.log(icon instanceof IconEntity); // true
     *
     * @example
     * // Array of entities
     * const rows = await IconModel.query().where({ is_active: true });
     * const icons = this.wrapEntity(rows, IconEntity);
     * console.log(icons[0] instanceof IconEntity); // true
     */
    wrapEntity(result, entityClass, entityOptions = {}) {
        if (!entityClass) return result;

        const wrapSingle = (record) => {
            if (!record || typeof record !== 'object') return record;

            const plain = typeof record.toJSON === 'function' ? record.toJSON() : { ...record };

            const instance = typeof entityClass.from === 'function'
                ? entityClass.from(plain)
                : new entityClass(plain, entityOptions);

            return instance;
        };

        return Array.isArray(result) ? result.map(wrapSingle) : wrapSingle(result);
    }

    /**
     * Finds a record by its ID
     */
    async findById(id, { entityClass = this.entityClass, entityOptions = {}, trx } = {}) {
        const record = await this.model.query(trx).findById(id);
        const entity = this.wrapEntity(record, entityClass, entityOptions);
        return this.finalize(entity, 'afterFind');
    }

    /**
     * Finds a record matching a where clause
     */
    async findOne(where = {}, { entityClass = this.entityClass, entityOptions = {}, trx } = {}) {
        const record = await this.model.query(trx).findOne(where);
        const entity = this.wrapEntity(record, entityClass, entityOptions);
        return this.finalize(entity, 'afterFind');
    }

    /**
     * Finds all records that match the given criteria
     */
    async findAll(where = {}, { entityClass = this.entityClass, entityOptions = {}, trx } = {}) {
        const query = this.model.query(trx);
        if (Object.keys(where).length > 0) {
            query.where(where);
        }
        const records = await query;
        const entities = this.wrapEntity(records, entityClass, entityOptions);
        return this.finalize(entities, 'afterList');
    }

    /**
     * Finds records by their IDs
     */
    async findByIds(ids = [], { entityClass = this.entityClass, entityOptions = {}, trx } = {}) {
        const records = await this.model.query(trx).findByIds(ids);
        const entities = this.wrapEntity(records, entityClass, entityOptions);
        return this.finalize(entities, 'afterList');
    }

    /**
     * Paginate query results with offset-based pagination.
     *
     * Returns paginated results with metadata (total count, page info). Uses Objection's
     * .page() method which efficiently counts total rows and fetches the requested page.
     *
     * **Note:** Offset pagination doesn't scale well beyond ~100K rows. For large datasets,
     * consider cursor-based pagination (currently not implemented).
     *
     * @param {Object} [where={}] - WHERE clause conditions
     * @param {number} [page=1] - Page number (1-indexed)
     * @param {number} [pageSize=10] - Items per page
     * @param {Object} [options={}] - Query options
     * @param {Function} [options.entityClass] - Entity class override
     * @param {Object} [options.entityOptions] - Entity constructor options
     * @param {Object} [options.trx] - Knex transaction object
     * @returns {Promise<Object>} Pagination result:
     *   - results: Array of Entity instances
     *   - total: Total row count
     *   - page: Current page (1-indexed)
     *   - pageSize: Items per page
     *   - totalPages: Total page count
     *
     * @example
     * const result = await iconRepo.paginate(
     *   { is_active: true },
     *   1,  // page
     *   20  // pageSize
     * );
     * console.log(result.results.length); // 20
     * console.log(result.total); // 150000
     * console.log(result.totalPages); // 7500
     */
    async paginate(where = {}, page = 1, pageSize = 10, { entityClass = this.entityClass, entityOptions = {}, trx } = {}) {
        const offsetPage = Math.max(page - 1, 0);
        const { results, total } = await this.model.query(trx).where(where).page(offsetPage, pageSize);
        const entities = this.wrapEntity(results, entityClass, entityOptions);
        const frozenResults = await this.finalize(entities, 'afterList');
        const pageObj = { results: frozenResults, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
        return this.freezeEntities ? deepFreeze(pageObj) : pageObj;
    }

    /**
     * Cursor-based pagination (stub).
     */
    async cursorPage(where = {}, order = [], { limit = 20, cursor = null, trx } = {}) {
        throw new Error('BaseRepository.cursorPage is not implemented yet. TODO: Implement cursor-based pagination.');
    }

    /**
     * Creates a new record
     */
    async create(data, { trx } = {}) {
        const rec = await this.model.query(trx).insert(data).returning('*');
        const entity = this.wrapEntity(rec, this.entityClass);
        return this.finalize(entity, null);
    }

    /**
     * Creates multiple records
     */
    async createMany(records = [], { trx } = {}) {
        return await this.model.query(trx).insert(records);
    }

    /**
     * Upserts a record (insert or update)
     */
    async upsert(data, whereClause = {}, { trx } = {}) {
        const where = Object.keys(whereClause).length > 0
            ? whereClause
            : data.id ? { id: data.id } : null;

        if (!where) {
            throw new Error('Upsert requires either `id` in data or a `whereClause`');
        }

        const existing = await this.model.query(trx).findOne(where);
        if (existing) {
            const requiredFields = this.model.jsonSchema?.required ?? [];
            const nullViolations = requiredFields.filter(
                (key) => Object.prototype.hasOwnProperty.call(data, key) && data[key] === null
            );
            if (nullViolations.length > 0) {
                throw new Error(`Cannot set required field(s) to null: ${nullViolations.join(', ')}`);
            }

            await this.model.query(trx)
                .context({ skipValidation: true })
                .findById(existing.id)
                .patch(data);

            const updatedRecord = await this.model.query(trx).findById(existing.id);
            const entity = this.wrapEntity(updatedRecord, this.entityClass);
            return this.finalize(entity, null);
        }

        const inserted = await this.model.query(trx).insert(data);
        const entity = this.wrapEntity(inserted, this.entityClass);
        return this.finalize(entity, null);
    }

    /**
     * Updates a record by its ID
     * NOTE: mirrors your existing behavior (returns patch result wrapped).
     */
    async update(id, data, { trx } = {}) {
        const rec = await this.model.query(trx).findById(id).patch(data);
        const entity = this.wrapEntity(rec, this.entityClass);
        return this.finalize(entity, null);
    }

    /**
     * Updates records that match the given criteria
     */
    async updateWhere(where = {}, data, { trx } = {}) {
        return await this.model.query(trx).where(where).patch(data);
    }

    /**
     * Deletes a record by its ID
     */
    async delete(id, { trx } = {}) {
        return await this.model.query(trx).deleteById(id);
    }

    /**
     * Deletes records that match the given criteria
     */
    async deleteWhere(where = {}, { trx } = {}) {
        return await this.model.query(trx).where(where).delete();
    }

    /**
     * Checks if a record exists that matches the given criteria
     */
    async exists(where = {}, { trx } = {}) {
        const result = await this.model.query(trx).findOne(where);
        return !!result;
    }

    /**
     * Counts the number of records that match the given criteria
     */
    async count(where = {}, { trx } = {}) {
        const result = await this.model.query(trx).where(where).count().first();
        return parseInt(result.count, 10);
    }

    /** 
     * Finds records with related data (no afterList hook to avoid relation mutations)
     */
    async withRelations(
        where = {},
        graph = '',
        { entityClass = this.entityClass, entityOptions = {}, trx, modifiers } = {}
    ) {
        let queryBuilder = this.model.query(trx);
        if (Object.keys(where).length) queryBuilder = queryBuilder.where(where);
        if (graph) queryBuilder = queryBuilder.withGraphFetched(graph);
        if (modifiers) queryBuilder = queryBuilder.modifiers(modifiers);

        const records = await queryBuilder;
        const entities = this.wrapEntity(records, entityClass, entityOptions);
        return this.finalize(entities, null);
    }

    /** 
     * Finds a single record with related data (no afterFind hook to avoid relation mutations)
     */
    async findOneWithRelations(
        where = {},
        graph = '',
        { entityClass = this.entityClass, entityOptions = {}, trx, modifiers } = {}
    ) {
        let queryBuilder = this.model.query(trx).findOne(where);
        if (graph) queryBuilder = queryBuilder.withGraphFetched(graph);
        if (modifiers) queryBuilder = queryBuilder.modifiers(modifiers);

        const record = await queryBuilder;
        const entity = this.wrapEntity(record, entityClass, entityOptions);
        return this.finalize(entity, null);
    }

    /**
     * Returns a query builder for the model
     */
    query({ trx } = {}) {
        return this.model.query(trx);
    }

    /**
     * Executes a raw SQL query
     */
    raw(sql, bindings = [], { trx } = {}) {
        return this.DB.knex.raw(sql, bindings, { trx });
    }
}

module.exports = BaseRepository;