From 4dba4f5704674ffc5317e28497baf04a23561a3e Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 19 Apr 2026 13:32:18 -0400 Subject: [PATCH 1/5] Refactor haro.js: consolidate duplicate code patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract #freezeResult(), #fromCache(), and #toCache() private helpers to eliminate repeated Object.freeze return patterns across 10+ methods. Consolidate #getIndexKeys and #getIndexKeysForWhere into a single parametrized #getIndexKeysFrom method (2 methods → 1, removing 62 lines of duplicate code). --- coverage.txt | 4 +- dist/haro.cjs | 159 ++++++++++++++++++++------------------------------ dist/haro.js | 159 ++++++++++++++++++++------------------------------ src/haro.js | 159 ++++++++++++++++++++------------------------------ 4 files changed, 188 insertions(+), 293 deletions(-) diff --git a/coverage.txt b/coverage.txt index c03cd9f..ffaadb2 100644 --- a/coverage.txt +++ b/coverage.txt @@ -4,8 +4,8 @@ ℹ -------------------------------------------------------------- ℹ src | | | | ℹ constants.js | 100.00 | 100.00 | 100.00 | -ℹ haro.js | 100.00 | 97.92 | 98.73 | +ℹ haro.js | 99.73 | 97.50 | 97.62 | 829-831 ℹ -------------------------------------------------------------- -ℹ all files | 100.00 | 97.93 | 98.73 | +ℹ all files | 99.74 | 97.51 | 97.62 | ℹ -------------------------------------------------------------- ℹ end of coverage report diff --git a/dist/haro.cjs b/dist/haro.cjs index c5b3ba9..4ad218a 100644 --- a/dist/haro.cjs +++ b/dist/haro.cjs @@ -256,6 +256,45 @@ class Haro { /* node:coverage ignore */ return JSON.parse(JSON.stringify(arg)); } + /** + * Freezes a result when immutable mode is enabled. + * @param {Array|Object} result - Value to freeze + * @returns {Array|Object} Frozen or unchanged result + */ + #freezeResult(result) { + if (this.#immutable) { + if (Array.isArray(result)) { + for (let i = 0, len = result.length; i < len; i++) { + Object.freeze(result[i]); + } + Object.freeze(result); + } else { + Object.freeze(result); + } + } + return result; + } + + /** + * Returns a cached result, cloned or frozen based on immutable mode. + * @param {*} cached - Cached value + * @returns {*} Cloned (non-immutable) or frozen (immutable) result + */ + #fromCache(cached) { + return this.#immutable ? Object.freeze(cached) : this.#clone(cached); + } + + /** + * Stores results in cache if enabled. + * @param {*} cacheKey - Cache key + * @param {Array} records - Result records to cache + */ + #toCache(cacheKey, records) { + if (this.#cacheEnabled) { + this.#cache.set(cacheKey, records); + } + } + /** * Deletes a record and removes it from all indexes. * @param {string} [key=STRING_EMPTY] - Key to delete @@ -368,7 +407,7 @@ class Haro { const idx = this.#indexes.get(i); if (!idx) return; const values = i.includes(this.#delimiter) - ? this.#getIndexKeys(i, this.#delimiter, data) + ? this.#getIndexKeysFrom(i, data, (f, s) => this.#getNestedValue(s, f)) : Array.isArray(this.#getNestedValue(data, i)) ? this.#getNestedValue(data, i) : [this.#getNestedValue(data, i)]; @@ -415,58 +454,20 @@ class Haro { } /** - * Generates index keys for composite indexes from data object. + * Generates index keys from source object using a value getter function. * @param {string} arg - Composite index field names * @param {string} delimiter - Field delimiter - * @param {Object} data - Data object + * @param {Object} source - Source object (data or where clause) + * @param {Function} getValueFn - Function(field, source) => value * @returns {string[]} Index keys */ - #getIndexKeys(arg, delimiter, data) { + #getIndexKeysFrom(arg, source, getValueFn) { const fields = arg.split(this.#delimiter).sort(this.#sortKeys); const result = [STRING_EMPTY]; const fieldsLen = fields.length; for (let i = 0; i < fieldsLen; i++) { const field = fields[i]; - const fieldValue = this.#getNestedValue(data, field); - const values = Array.isArray(fieldValue) ? fieldValue : [fieldValue]; - const newResult = []; - const resultLen = result.length; - const valuesLen = values.length; - for (let j = 0; j < resultLen; j++) { - const existing = result[j]; - for (let k = 0; k < valuesLen; k++) { - const value = values[k]; - const newKey = i === 0 ? value : `${existing}${this.#delimiter}${value}`; - newResult.push(newKey); - } - } - result.length = 0; - result.push(...newResult); - } - return result; - } - - /** - * Generates index keys for where object (handles both dot notation and direct access). - * @param {string} arg - Composite index field names - * @param {string} delimiter - Field delimiter - * @param {Object} where - Where object - * @returns {string[]} Index keys - */ - #getIndexKeysForWhere(arg, delimiter, where) { - const fields = arg.split(this.#delimiter).sort(this.#sortKeys); - const result = [STRING_EMPTY]; - const fieldsLen = fields.length; - for (let i = 0; i < fieldsLen; i++) { - const field = fields[i]; - // Check if field exists directly in where object first (for dot notation keys) - let fieldValue; - if (field in where) { - fieldValue = where[field]; - /* node:coverage ignore next 4 */ - } else { - fieldValue = this.#getNestedValue(where, field); - } + const fieldValue = getValueFn(field, source); const values = Array.isArray(fieldValue) ? fieldValue : [fieldValue]; const newResult = []; const resultLen = result.length; @@ -512,7 +513,9 @@ class Haro { const index = this.#indexes.get(compositeKey); if (index) { - const keys = this.#getIndexKeysForWhere(compositeKey, this.#delimiter, where); + const keys = this.#getIndexKeysFrom(compositeKey, where, (f, s) => + f in s ? s[f] : this.#getNestedValue(s, f), + ); const keysLen = keys.length; for (let i = 0; i < keysLen; i++) { const v = keys[i]; @@ -525,11 +528,7 @@ class Haro { } } - const records = Array.from(result, (i) => this.get(i)); - if (this.#immutable) { - return Object.freeze(records); - } - return records; + return this.#freezeResult(Array.from(result, (i) => this.get(i))); } /** @@ -550,10 +549,7 @@ class Haro { result.push(value); } }); - if (this.#immutable) { - return Object.freeze(result); - } - return result; + return this.#freezeResult(result); } /** @@ -587,10 +583,7 @@ class Haro { if (result === undefined) { return null; } - if (this.#immutable) { - return Object.freeze(result); - } - return result; + return this.#freezeResult(result); } /** @@ -629,12 +622,7 @@ class Haro { if (typeof max !== STRING_NUMBER) { throw new Error(STRING_ERROR_LIMIT_MAX_TYPE); } - let result = this.registry.slice(offset, offset + max).map((i) => this.get(i)); - if (this.#immutable) { - result = Object.freeze(result); - } - - return result; + return this.#freezeResult(this.registry.slice(offset, offset + max).map((i) => this.get(i))); } /** @@ -651,11 +639,7 @@ class Haro { } let result = []; this.forEach((value, key) => result.push(fn(value, key))); - if (this.#immutable) { - result = Object.freeze(result); - } - - return result; + return this.#freezeResult(result); } /** @@ -762,7 +746,7 @@ class Haro { cacheKey = await this.#getCacheKey(STRING_CACHE_DOMAIN_SEARCH, value, index); const cached = this.#cache.get(cacheKey); if (cached !== undefined) { - return this.#immutable ? Object.freeze(cached) : this.#clone(cached); + return this.#fromCache(cached); } } @@ -799,14 +783,8 @@ class Haro { } const records = Array.from(result, (key) => this.get(key)); - if (this.#cacheEnabled) { - this.#cache.set(cacheKey, records); - } - - if (this.#immutable) { - return Object.freeze(records); - } - return records; + this.#toCache(cacheKey, records); + return this.#freezeResult(records); } /** @@ -876,7 +854,7 @@ class Haro { this.#indexes.set(field, idx); } const values = field.includes(this.#delimiter) - ? this.#getIndexKeys(field, this.#delimiter, data) + ? this.#getIndexKeysFrom(field, data, (f, s) => this.#getNestedValue(s, f)) : Array.isArray(this.#getNestedValue(data, field)) ? this.#getNestedValue(data, field) : [this.#getNestedValue(data, field)]; @@ -959,10 +937,7 @@ class Haro { return mapped; }); - if (this.#immutable) { - return Object.freeze(result); - } - return result; + return this.#freezeResult(result); } /** @@ -974,11 +949,7 @@ class Haro { toArray() { const result = Array.from(this.#data.values()); if (this.#immutable) { - const resultLen = result.length; - for (let i = 0; i < resultLen; i++) { - Object.freeze(result[i]); - } - Object.freeze(result); + return this.#freezeResult(result); } return result; @@ -1058,7 +1029,7 @@ class Haro { cacheKey = await this.#getCacheKey(STRING_CACHE_DOMAIN_WHERE, predicate, op); const cached = this.#cache.get(cacheKey); if (cached !== undefined) { - return this.#immutable ? Object.freeze(cached) : this.#clone(cached); + return this.#fromCache(cached); } } @@ -1130,14 +1101,8 @@ class Haro { } } - if (this.#cacheEnabled) { - this.#cache.set(cacheKey, results); - } - - if (this.#immutable) { - return Object.freeze(results); - } - return results; + this.#toCache(cacheKey, results); + return this.#freezeResult(results); } } } diff --git a/dist/haro.js b/dist/haro.js index 022412a..344a382 100644 --- a/dist/haro.js +++ b/dist/haro.js @@ -249,6 +249,45 @@ class Haro { /* node:coverage ignore */ return JSON.parse(JSON.stringify(arg)); } + /** + * Freezes a result when immutable mode is enabled. + * @param {Array|Object} result - Value to freeze + * @returns {Array|Object} Frozen or unchanged result + */ + #freezeResult(result) { + if (this.#immutable) { + if (Array.isArray(result)) { + for (let i = 0, len = result.length; i < len; i++) { + Object.freeze(result[i]); + } + Object.freeze(result); + } else { + Object.freeze(result); + } + } + return result; + } + + /** + * Returns a cached result, cloned or frozen based on immutable mode. + * @param {*} cached - Cached value + * @returns {*} Cloned (non-immutable) or frozen (immutable) result + */ + #fromCache(cached) { + return this.#immutable ? Object.freeze(cached) : this.#clone(cached); + } + + /** + * Stores results in cache if enabled. + * @param {*} cacheKey - Cache key + * @param {Array} records - Result records to cache + */ + #toCache(cacheKey, records) { + if (this.#cacheEnabled) { + this.#cache.set(cacheKey, records); + } + } + /** * Deletes a record and removes it from all indexes. * @param {string} [key=STRING_EMPTY] - Key to delete @@ -361,7 +400,7 @@ class Haro { const idx = this.#indexes.get(i); if (!idx) return; const values = i.includes(this.#delimiter) - ? this.#getIndexKeys(i, this.#delimiter, data) + ? this.#getIndexKeysFrom(i, data, (f, s) => this.#getNestedValue(s, f)) : Array.isArray(this.#getNestedValue(data, i)) ? this.#getNestedValue(data, i) : [this.#getNestedValue(data, i)]; @@ -408,58 +447,20 @@ class Haro { } /** - * Generates index keys for composite indexes from data object. + * Generates index keys from source object using a value getter function. * @param {string} arg - Composite index field names * @param {string} delimiter - Field delimiter - * @param {Object} data - Data object + * @param {Object} source - Source object (data or where clause) + * @param {Function} getValueFn - Function(field, source) => value * @returns {string[]} Index keys */ - #getIndexKeys(arg, delimiter, data) { + #getIndexKeysFrom(arg, source, getValueFn) { const fields = arg.split(this.#delimiter).sort(this.#sortKeys); const result = [STRING_EMPTY]; const fieldsLen = fields.length; for (let i = 0; i < fieldsLen; i++) { const field = fields[i]; - const fieldValue = this.#getNestedValue(data, field); - const values = Array.isArray(fieldValue) ? fieldValue : [fieldValue]; - const newResult = []; - const resultLen = result.length; - const valuesLen = values.length; - for (let j = 0; j < resultLen; j++) { - const existing = result[j]; - for (let k = 0; k < valuesLen; k++) { - const value = values[k]; - const newKey = i === 0 ? value : `${existing}${this.#delimiter}${value}`; - newResult.push(newKey); - } - } - result.length = 0; - result.push(...newResult); - } - return result; - } - - /** - * Generates index keys for where object (handles both dot notation and direct access). - * @param {string} arg - Composite index field names - * @param {string} delimiter - Field delimiter - * @param {Object} where - Where object - * @returns {string[]} Index keys - */ - #getIndexKeysForWhere(arg, delimiter, where) { - const fields = arg.split(this.#delimiter).sort(this.#sortKeys); - const result = [STRING_EMPTY]; - const fieldsLen = fields.length; - for (let i = 0; i < fieldsLen; i++) { - const field = fields[i]; - // Check if field exists directly in where object first (for dot notation keys) - let fieldValue; - if (field in where) { - fieldValue = where[field]; - /* node:coverage ignore next 4 */ - } else { - fieldValue = this.#getNestedValue(where, field); - } + const fieldValue = getValueFn(field, source); const values = Array.isArray(fieldValue) ? fieldValue : [fieldValue]; const newResult = []; const resultLen = result.length; @@ -505,7 +506,9 @@ class Haro { const index = this.#indexes.get(compositeKey); if (index) { - const keys = this.#getIndexKeysForWhere(compositeKey, this.#delimiter, where); + const keys = this.#getIndexKeysFrom(compositeKey, where, (f, s) => + f in s ? s[f] : this.#getNestedValue(s, f), + ); const keysLen = keys.length; for (let i = 0; i < keysLen; i++) { const v = keys[i]; @@ -518,11 +521,7 @@ class Haro { } } - const records = Array.from(result, (i) => this.get(i)); - if (this.#immutable) { - return Object.freeze(records); - } - return records; + return this.#freezeResult(Array.from(result, (i) => this.get(i))); } /** @@ -543,10 +542,7 @@ class Haro { result.push(value); } }); - if (this.#immutable) { - return Object.freeze(result); - } - return result; + return this.#freezeResult(result); } /** @@ -580,10 +576,7 @@ class Haro { if (result === undefined) { return null; } - if (this.#immutable) { - return Object.freeze(result); - } - return result; + return this.#freezeResult(result); } /** @@ -622,12 +615,7 @@ class Haro { if (typeof max !== STRING_NUMBER) { throw new Error(STRING_ERROR_LIMIT_MAX_TYPE); } - let result = this.registry.slice(offset, offset + max).map((i) => this.get(i)); - if (this.#immutable) { - result = Object.freeze(result); - } - - return result; + return this.#freezeResult(this.registry.slice(offset, offset + max).map((i) => this.get(i))); } /** @@ -644,11 +632,7 @@ class Haro { } let result = []; this.forEach((value, key) => result.push(fn(value, key))); - if (this.#immutable) { - result = Object.freeze(result); - } - - return result; + return this.#freezeResult(result); } /** @@ -755,7 +739,7 @@ class Haro { cacheKey = await this.#getCacheKey(STRING_CACHE_DOMAIN_SEARCH, value, index); const cached = this.#cache.get(cacheKey); if (cached !== undefined) { - return this.#immutable ? Object.freeze(cached) : this.#clone(cached); + return this.#fromCache(cached); } } @@ -792,14 +776,8 @@ class Haro { } const records = Array.from(result, (key) => this.get(key)); - if (this.#cacheEnabled) { - this.#cache.set(cacheKey, records); - } - - if (this.#immutable) { - return Object.freeze(records); - } - return records; + this.#toCache(cacheKey, records); + return this.#freezeResult(records); } /** @@ -869,7 +847,7 @@ class Haro { this.#indexes.set(field, idx); } const values = field.includes(this.#delimiter) - ? this.#getIndexKeys(field, this.#delimiter, data) + ? this.#getIndexKeysFrom(field, data, (f, s) => this.#getNestedValue(s, f)) : Array.isArray(this.#getNestedValue(data, field)) ? this.#getNestedValue(data, field) : [this.#getNestedValue(data, field)]; @@ -952,10 +930,7 @@ class Haro { return mapped; }); - if (this.#immutable) { - return Object.freeze(result); - } - return result; + return this.#freezeResult(result); } /** @@ -967,11 +942,7 @@ class Haro { toArray() { const result = Array.from(this.#data.values()); if (this.#immutable) { - const resultLen = result.length; - for (let i = 0; i < resultLen; i++) { - Object.freeze(result[i]); - } - Object.freeze(result); + return this.#freezeResult(result); } return result; @@ -1051,7 +1022,7 @@ class Haro { cacheKey = await this.#getCacheKey(STRING_CACHE_DOMAIN_WHERE, predicate, op); const cached = this.#cache.get(cacheKey); if (cached !== undefined) { - return this.#immutable ? Object.freeze(cached) : this.#clone(cached); + return this.#fromCache(cached); } } @@ -1123,14 +1094,8 @@ class Haro { } } - if (this.#cacheEnabled) { - this.#cache.set(cacheKey, results); - } - - if (this.#immutable) { - return Object.freeze(results); - } - return results; + this.#toCache(cacheKey, results); + return this.#freezeResult(results); } } } diff --git a/src/haro.js b/src/haro.js index 3f509f5..2226495 100644 --- a/src/haro.js +++ b/src/haro.js @@ -232,6 +232,45 @@ export class Haro { /* node:coverage ignore */ return JSON.parse(JSON.stringify(arg)); } + /** + * Freezes a result when immutable mode is enabled. + * @param {Array|Object} result - Value to freeze + * @returns {Array|Object} Frozen or unchanged result + */ + #freezeResult(result) { + if (this.#immutable) { + if (Array.isArray(result)) { + for (let i = 0, len = result.length; i < len; i++) { + Object.freeze(result[i]); + } + Object.freeze(result); + } else { + Object.freeze(result); + } + } + return result; + } + + /** + * Returns a cached result, cloned or frozen based on immutable mode. + * @param {*} cached - Cached value + * @returns {*} Cloned (non-immutable) or frozen (immutable) result + */ + #fromCache(cached) { + return this.#immutable ? Object.freeze(cached) : this.#clone(cached); + } + + /** + * Stores results in cache if enabled. + * @param {*} cacheKey - Cache key + * @param {Array} records - Result records to cache + */ + #toCache(cacheKey, records) { + if (this.#cacheEnabled) { + this.#cache.set(cacheKey, records); + } + } + /** * Deletes a record and removes it from all indexes. * @param {string} [key=STRING_EMPTY] - Key to delete @@ -344,7 +383,7 @@ export class Haro { const idx = this.#indexes.get(i); if (!idx) return; const values = i.includes(this.#delimiter) - ? this.#getIndexKeys(i, this.#delimiter, data) + ? this.#getIndexKeysFrom(i, data, (f, s) => this.#getNestedValue(s, f)) : Array.isArray(this.#getNestedValue(data, i)) ? this.#getNestedValue(data, i) : [this.#getNestedValue(data, i)]; @@ -391,58 +430,20 @@ export class Haro { } /** - * Generates index keys for composite indexes from data object. + * Generates index keys from source object using a value getter function. * @param {string} arg - Composite index field names * @param {string} delimiter - Field delimiter - * @param {Object} data - Data object + * @param {Object} source - Source object (data or where clause) + * @param {Function} getValueFn - Function(field, source) => value * @returns {string[]} Index keys */ - #getIndexKeys(arg, delimiter, data) { + #getIndexKeysFrom(arg, source, getValueFn) { const fields = arg.split(this.#delimiter).sort(this.#sortKeys); const result = [STRING_EMPTY]; const fieldsLen = fields.length; for (let i = 0; i < fieldsLen; i++) { const field = fields[i]; - const fieldValue = this.#getNestedValue(data, field); - const values = Array.isArray(fieldValue) ? fieldValue : [fieldValue]; - const newResult = []; - const resultLen = result.length; - const valuesLen = values.length; - for (let j = 0; j < resultLen; j++) { - const existing = result[j]; - for (let k = 0; k < valuesLen; k++) { - const value = values[k]; - const newKey = i === 0 ? value : `${existing}${this.#delimiter}${value}`; - newResult.push(newKey); - } - } - result.length = 0; - result.push(...newResult); - } - return result; - } - - /** - * Generates index keys for where object (handles both dot notation and direct access). - * @param {string} arg - Composite index field names - * @param {string} delimiter - Field delimiter - * @param {Object} where - Where object - * @returns {string[]} Index keys - */ - #getIndexKeysForWhere(arg, delimiter, where) { - const fields = arg.split(this.#delimiter).sort(this.#sortKeys); - const result = [STRING_EMPTY]; - const fieldsLen = fields.length; - for (let i = 0; i < fieldsLen; i++) { - const field = fields[i]; - // Check if field exists directly in where object first (for dot notation keys) - let fieldValue; - if (field in where) { - fieldValue = where[field]; - /* node:coverage ignore next 4 */ - } else { - fieldValue = this.#getNestedValue(where, field); - } + const fieldValue = getValueFn(field, source); const values = Array.isArray(fieldValue) ? fieldValue : [fieldValue]; const newResult = []; const resultLen = result.length; @@ -488,7 +489,9 @@ export class Haro { const index = this.#indexes.get(compositeKey); if (index) { - const keys = this.#getIndexKeysForWhere(compositeKey, this.#delimiter, where); + const keys = this.#getIndexKeysFrom(compositeKey, where, (f, s) => + f in s ? s[f] : this.#getNestedValue(s, f), + ); const keysLen = keys.length; for (let i = 0; i < keysLen; i++) { const v = keys[i]; @@ -501,11 +504,7 @@ export class Haro { } } - const records = Array.from(result, (i) => this.get(i)); - if (this.#immutable) { - return Object.freeze(records); - } - return records; + return this.#freezeResult(Array.from(result, (i) => this.get(i))); } /** @@ -526,10 +525,7 @@ export class Haro { result.push(value); } }); - if (this.#immutable) { - return Object.freeze(result); - } - return result; + return this.#freezeResult(result); } /** @@ -563,10 +559,7 @@ export class Haro { if (result === undefined) { return null; } - if (this.#immutable) { - return Object.freeze(result); - } - return result; + return this.#freezeResult(result); } /** @@ -605,12 +598,7 @@ export class Haro { if (typeof max !== STRING_NUMBER) { throw new Error(STRING_ERROR_LIMIT_MAX_TYPE); } - let result = this.registry.slice(offset, offset + max).map((i) => this.get(i)); - if (this.#immutable) { - result = Object.freeze(result); - } - - return result; + return this.#freezeResult(this.registry.slice(offset, offset + max).map((i) => this.get(i))); } /** @@ -627,11 +615,7 @@ export class Haro { } let result = []; this.forEach((value, key) => result.push(fn(value, key))); - if (this.#immutable) { - result = Object.freeze(result); - } - - return result; + return this.#freezeResult(result); } /** @@ -738,7 +722,7 @@ export class Haro { cacheKey = await this.#getCacheKey(STRING_CACHE_DOMAIN_SEARCH, value, index); const cached = this.#cache.get(cacheKey); if (cached !== undefined) { - return this.#immutable ? Object.freeze(cached) : this.#clone(cached); + return this.#fromCache(cached); } } @@ -775,14 +759,8 @@ export class Haro { } const records = Array.from(result, (key) => this.get(key)); - if (this.#cacheEnabled) { - this.#cache.set(cacheKey, records); - } - - if (this.#immutable) { - return Object.freeze(records); - } - return records; + this.#toCache(cacheKey, records); + return this.#freezeResult(records); } /** @@ -852,7 +830,7 @@ export class Haro { this.#indexes.set(field, idx); } const values = field.includes(this.#delimiter) - ? this.#getIndexKeys(field, this.#delimiter, data) + ? this.#getIndexKeysFrom(field, data, (f, s) => this.#getNestedValue(s, f)) : Array.isArray(this.#getNestedValue(data, field)) ? this.#getNestedValue(data, field) : [this.#getNestedValue(data, field)]; @@ -935,10 +913,7 @@ export class Haro { return mapped; }); - if (this.#immutable) { - return Object.freeze(result); - } - return result; + return this.#freezeResult(result); } /** @@ -950,11 +925,7 @@ export class Haro { toArray() { const result = Array.from(this.#data.values()); if (this.#immutable) { - const resultLen = result.length; - for (let i = 0; i < resultLen; i++) { - Object.freeze(result[i]); - } - Object.freeze(result); + return this.#freezeResult(result); } return result; @@ -1034,7 +1005,7 @@ export class Haro { cacheKey = await this.#getCacheKey(STRING_CACHE_DOMAIN_WHERE, predicate, op); const cached = this.#cache.get(cacheKey); if (cached !== undefined) { - return this.#immutable ? Object.freeze(cached) : this.#clone(cached); + return this.#fromCache(cached); } } @@ -1106,14 +1077,8 @@ export class Haro { } } - if (this.#cacheEnabled) { - this.#cache.set(cacheKey, results); - } - - if (this.#immutable) { - return Object.freeze(results); - } - return results; + this.#toCache(cacheKey, results); + return this.#freezeResult(results); } } } From dfd268f008c5f45e4fe18cb2b005bfbe27caca35 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 19 Apr 2026 13:35:00 -0400 Subject: [PATCH 2/5] test: cover #setIndex() branch recreated after store clear Add 'should recreate index after clear on same store' test to achieve 100% line coverage across haro.js. --- coverage.txt | 4 ++-- tests/unit/crud.test.js | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/coverage.txt b/coverage.txt index ffaadb2..4652cdf 100644 --- a/coverage.txt +++ b/coverage.txt @@ -4,8 +4,8 @@ ℹ -------------------------------------------------------------- ℹ src | | | | ℹ constants.js | 100.00 | 100.00 | 100.00 | -ℹ haro.js | 99.73 | 97.50 | 97.62 | 829-831 +ℹ haro.js | 100.00 | 97.86 | 97.62 | ℹ -------------------------------------------------------------- -ℹ all files | 99.74 | 97.51 | 97.62 | +ℹ all files | 100.00 | 97.86 | 97.62 | ℹ -------------------------------------------------------------- ℹ end of coverage report diff --git a/tests/unit/crud.test.js b/tests/unit/crud.test.js index e67dd7d..146d9f3 100644 --- a/tests/unit/crud.test.js +++ b/tests/unit/crud.test.js @@ -180,5 +180,19 @@ describe("Basic CRUD Operations", () => { assert.strictEqual(versionedStore.versions.size, 0); }); + + it("should recreate index after clear on same store", () => { + const indexedStore = new Haro({ index: ["name"] }); + indexedStore.set("user1", { id: "user1", name: "John" }); + assert.strictEqual(indexedStore.find({ name: "John" }).length, 1); + + indexedStore.clear(); + assert.strictEqual(indexedStore.size, 0); + + indexedStore.set("user2", { id: "user2", name: "Jane" }); + const results = indexedStore.find({ name: "Jane" }); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].id, "user2"); + }); }); }); From e6eadd2f7450214ea191bb840dd144d20c94d411 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 19 Apr 2026 13:37:00 -0400 Subject: [PATCH 3/5] docs: expand AGENTS.md with internal helpers, patterns, and test strategy --- AGENTS.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 34503ff..d345277 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,49 @@ npm run benchmark # Run benchmarks - Adheres to DRY, YAGNI, and SOLID principles - Follows OWASP security guidance +## Internal Helper Methods +- `#freezeResult(result)` - Freezes individual values or arrays when immutable mode is enabled +- `#fromCache(cached)` - Returns cloned result (non-immutable) or frozen result (immutable) from cache +- `#toCache(cacheKey, records)` - Stores results in cache if enabled +- `#getIndexKeysFrom(arg, source, getValueFn)` - Generates composite index keys using a getter callback +- `#getNestedValue(obj, path)` - Retrieves nested values using dot notation (e.g., `user.profile.city`) +- `#sortKeys(a, b)` - Type-aware comparator: strings use `localeCompare`, numbers use subtraction, mixed types coerced to string +- `#merge(a, b, override)` - Deep merges values, skips prototype pollution keys (`__proto__`, `constructor`, `prototype`) +- `#invalidateCache()` - Clears cache if enabled and not in batch mode +- `#getCacheKey(domain, ...args)` - Generates SHA-256 hash cache key from arguments +- `#clone(arg)` - Deep clones values via `structuredClone` or JSON fallback + +## Immutable Mode +- Use `#freezeResult()` to return frozen data instead of inline `Object.freeze()` checks +- Applies to: `get()`, `find()`, `filter()`, `limit()`, `map()`, `toArray()`, `sort()`, `sortBy()`, `search()`, `where()` +- Cached results are also frozen/cloned via `#fromCache()` when read +- `#merge()` preserves version snapshots by freezing cloned originals before versioning + +## Caching +- Cache is opt-in via `cache: true` in constructor +- Automatically invalidates on all write operations (set, delete, clear, reindex, setMany, deleteMany, override) +- Does NOT invalidate during batch operations (`#inBatch = true`), only after batch completes +- `search()` and `where()` use multi-domain cache keys: `search_HASH` / `where_HASH` +- LRU cache size defaults to 1000 (`CACHE_SIZE_DEFAULT`) + +## Indexing +- `#index` config array persists across `clear()` - `clearIndexes` is separate +- After `clear()`, `#indexes` Map is emptied but `#index` config remains +- Setting new records after `clear()` triggers lazy re-creation of index Maps in `#setIndex()` +- Composite indexes use delimiter (default `|`) to join sorted field names +- Use `#getIndexKeysFrom()` with appropriate getter callbacks for data objects vs where clauses + +## Batch Operations +- `setMany()` and `deleteMany()` set `#inBatch = true` to skip indexing during individual operations +- Indexing is deferred to `reindex()` call after batch completes +- Nested batch calls (calling setMany within setMany) throw errors +- `#inBatch` affects versioning too - versions are not saved during batch operations + +## Test Strategy Tips +- Hard-to-reach branches often involve state transitions (e.g., `clear()` → `set()` on same store) +- Coverage gaps frequently involve conditional logic on `#inBatch`, `#immutable`, or `#cacheEnabled` +- Add tests that explicitly combine features (immutable + indexing + batch + caching) + ## Important Notes - The `immutable` option freezes data for immutability - Indexes improve query performance for `find()` and `where()` operations @@ -62,3 +105,10 @@ npm run benchmark # Run benchmarks - Cache invalidates on all write operations but preserves statistics - `search()` and `where()` are async methods - use `await` when calling - Cache statistics persist for the lifetime of the Haro instance + +## Refactoring Patterns +- Centralize immutable freezing in `#freezeResult()` - never use inline `Object.freeze()` for return values +- Centralize cache read patterns in `#fromCache(cached)` and cache write in `#toCache(key, records)` +- Consolidate index key generation in `#getIndexKeysFrom()` using a getter callback parameter +- When DRY requires a single function to handle both data objects and where clauses, use a `getValueFn` callback parameter +- Always run `npm test` after structural refactors - helper extraction changes call signatures From 2bdf030f58b2dee408099ce0d36dd33513d3b79c Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 19 Apr 2026 13:39:51 -0400 Subject: [PATCH 4/5] docs: update API and technical docs with internal helper methods - Remove clone(), freeze(), merge() from public API (all are private) - Add Internal Helper Methods section documenting #freezeResult, #fromCache, #toCache, #getIndexKeysFrom - Update Immutability Model to reference centralized #freezeResult() - Update cache mutation protection to reference #fromCache/#toCache - Clarify internal vs public utility methods --- docs/API.md | 64 ++++++++++++++++++++------------- docs/TECHNICAL_DOCUMENTATION.md | 27 ++++++++++++-- 2 files changed, 63 insertions(+), 28 deletions(-) diff --git a/docs/API.md b/docs/API.md index beb0e46..6f327a0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -33,10 +33,11 @@ Haro is an immutable DataStore with indexing, versioning, and batch operations. - [values()](#values) - [forEach()](#foreachfn-ctx) - [map()](#mapfn) -- [Utility Methods](#utility-methods) - - [clone()](#clonearg) - - [freeze()](#freezeargs) - - [merge()](#mergea-b-override) +- [Internal Helper Methods](#internal-helper-methods) + - [#freezeResult(result)](#freezeresultresult) + - [#fromCache(cached)](#fromcachecached) + - [#toCache(cacheKey, records)](#tocachekey-records) + - [#getIndexKeysFrom(arg, source, getValueFn)](#getindexkeysfromarg-source-getvaluefn) - [Index Management](#index-management) - [reindex()](#reindexindex) - [Cache Control Methods](#cache-control-methods) @@ -446,54 +447,67 @@ store.map(record => record.name); --- -## Utility Methods +## Internal Helper Methods -### clone(arg) +Haro uses internal private helper methods for cloning, freezing, and caching operations. These are not part of the public API. -Creates a deep clone of a value. +### #freezeResult(result) + +Freezes individual values or arrays when immutable mode is enabled. Replaces inline `Object.freeze()` checks throughout the codebase. **Parameters:** -- `arg` (*): Value to clone +- `result` (*): Value to freeze + +**Returns:** * - Frozen or unchanged result -**Returns:** * - Deep clone +**Applies to:** `get()`, `find()`, `filter()`, `limit()`, `map()`, `toArray()`, `sort()`, `sortBy()`, `search()`, `where()` **Example:** ```javascript -store.clone({name: 'John', tags: ['user']}); +// Internal - used automatically by all query methods when immutable: true ``` ---- - -### freeze(...args) +### #fromCache(cached) -Creates a frozen array from arguments. +Returns a cached result, cloning when mutable or freezing when immutable. **Parameters:** -- `args` (...*): Arguments to freeze +- `cached` (*): Cached value -**Returns:** Array<*> - Frozen array +**Returns:** * - Cloned (non-immutable) or frozen (immutable) result **Example:** ```javascript -store.freeze(obj1, obj2); +// Internal - used by search() and where() for cache read ``` ---- +### #toCache(cacheKey, records) + +Stores results in cache when enabled. + +**Parameters:** +- `cacheKey` (*): Cache key +- `records` (Array): Result records to cache + +**Example:** +```javascript +// Internal - used by search() and where() for cache write +``` -### merge(a, b, override) +### #getIndexKeysFrom(arg, source, getValueFn) -Merges two values. +Generates composite index keys from data objects or where clauses using a value getter callback. Consolidates the former `#getIndexKeys` and `#getIndexKeysForWhere` methods. **Parameters:** -- `a` (*): Target value -- `b` (*): Source value -- `override` (boolean): Override arrays (default: `false`) +- `arg` (string): Composite index field names +- `source` (Object): Source object (data or where clause) +- `getValueFn` (Function): Callback `(field, source) => value` -**Returns:** * - Merged result +**Returns:** string[] - Index keys **Example:** ```javascript -store.merge({a: 1}, {b: 2}); +// Internal - used by setIndex(), deleteIndex(), and find() ``` --- diff --git a/docs/TECHNICAL_DOCUMENTATION.md b/docs/TECHNICAL_DOCUMENTATION.md index 652a929..7485ec2 100644 --- a/docs/TECHNICAL_DOCUMENTATION.md +++ b/docs/TECHNICAL_DOCUMENTATION.md @@ -197,6 +197,21 @@ graph LR class F,G,H,I performance ``` +### Internal Helper Methods + +Haro uses private helper methods to centralize cross-cutting concerns. These are not part of the public API. + +- `#freezeResult(result)` - Freezes individual values or arrays when immutable mode is enabled. Replaces inline `Object.freeze()` throughout all return paths in `get()`, `find()`, `filter()`, `limit()`, `map()`, `toArray()`, `sort()`, `sortBy()`, `search()`, and `where()`. +- `#fromCache(cached)` - Returns a cached result, cloning when mutable or freezing when immutable. Used by `search()` and `where()` for cache read. +- `#toCache(cacheKey, records)` - Stores results in cache when enabled. Used by `search()` and `where()` for cache write. +- `# getIndexKeysFrom(arg, source, getValueFn)` - Generates composite index keys using a value getter callback. Consolidates the former `#getIndexKeys` and `#getIndexKeysForWhere` methods. Used by `#setIndex()`, `#deleteIndex()`, and `find()`. +- `#getNestedValue(obj, path)` - Retrieves nested values using dot notation (e.g., `user.profile.city`). Used by indexing, querying, and predicate matching. +- `#sortKeys(a, b)` - Type-aware comparator: strings use `localeCompare`, numbers use subtraction, mixed types coerced to string. Used by `find()`, `sortBy()`, and index key generation. +- `#merge(a, b, override)` - Deep merges values, skipping prototype pollution keys (`__proto__`, `constructor`, `prototype`). Used internally by `set()`. +- `#invalidateCache()` - Clears cache if enabled and not in batch mode (`#inBatch`). Called by write operations and lazy re-creation after `clear()`. +- `#getCacheKey(domain, ...args)` - Generates SHA-256 hash cache key from arguments for `search()` and `where()`. +- `#clone(arg)` - Deep clones values via `structuredClone` or JSON fallback. Used by `forEach()` to ensure non-mutable callback data and version snapshots. + ### Index Maintenance ```mermaid @@ -350,18 +365,24 @@ Where `$\text{LRU\\_head}$` is the oldest accessed entry in the doubly-linked li ### Immutability Model -Objects are frozen using `Object.freeze()`. Formally: +Objects are frozen using `Object.freeze()` via a centralized `#freezeResult()` helper method. This ensures consistent freezing across all public API return paths. Formally: $$\text{freeze}(\text{obj}) = \text{obj} \text{ where } \forall \text{prop} \in \text{obj}: \text{prop is non-writable}$$ $$\text{deepFreeze}(\text{obj}) = \text{freeze}(\text{obj}) \text{ where } \forall \text{prop} \in \text{obj}: \text{deepFreeze}(\text{prop})$$ +**Centralized freezing:** All query methods (`get()`, `find()`, `filter()`, `limit()`, `map()`, `toArray()`, `sort()`, `sortBy()`, `search()`, `where()`) delegate to `#freezeResult(result)` which handles both single objects and arrays (freezing each element). Version snapshots in `#merge()` are also frozen via `Object.freeze(this.#clone(og))`. + **Cache Mutation Protection:** -When returning cached results, a deep clone is created to prevent mutation: +When returning cached results, a deep clone is created to prevent mutation, handled by `#fromCache(cached)`: $$\text{return} = \begin{cases} \text{freeze}(\text{clone}(\text{cached})) & \text{if immutable} \\ \text{clone}(\text{cached}) & \text{if mutable} \end{cases}$$ +**Cache write** is handled by `#toCache(cacheKey, records)` which stores raw records in the cache when enabled: + +$$\text{cache}.\text{set}(\text{cacheKey}, \text{records}) \text{ when } \text{cacheEnabled}$$ + ## Operations ### CRUD Operations Performance @@ -964,7 +985,7 @@ new Haro(config) ### Utility Methods -Haro uses internal utility methods for cloning and merging data. These are implementation details and not part of the public API. +Haro's internal utility methods (`#clone()`, `#merge()`, `#freezeResult()`, `#fromCache()`, `#toCache()`) are implementation details and not part of the public API. See [Internal Helper Methods](#internal-helper-methods) for details. ## Best Practices From 9bce9c3cb2b0410a462c599018d1a749dab46999 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 19 Apr 2026 13:42:52 -0400 Subject: [PATCH 5/5] docs: remove clone()/merge() from README Quick Overview (internal only) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6d9de3c..7ae69aa 100644 --- a/README.md +++ b/README.md @@ -425,7 +425,7 @@ For complete API documentation with all methods and examples, see [API.md](https - **Core Methods**: `set()`, `get()`, `delete()`, `has()`, `clear()` - **Query Methods**: `find()`, `where()`, `search()`, `filter()`, `sortBy()`, `limit()` - **Batch Operations**: `setMany()`, `deleteMany()` -- **Utility Methods**: `clone()`, `merge()`, `toArray()`, `dump()`, `override()` +- **Utility Methods**: `toArray()`, `dump()`, `override()` - **Properties**: `size`, `registry` ## Troubleshooting