Skip to content

Commit fa19e05

Browse files
authored
Refactor: consolidate duplicate code, fix coverage, update docs (#323)
* Refactor haro.js: consolidate duplicate code patterns 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). * 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. * docs: expand AGENTS.md with internal helpers, patterns, and test strategy * 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: remove clone()/merge() from README Quick Overview (internal only)
1 parent 946c700 commit fa19e05

File tree

9 files changed

+316
-322
lines changed

9 files changed

+316
-322
lines changed

AGENTS.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,49 @@ npm run benchmark # Run benchmarks
4949
- Adheres to DRY, YAGNI, and SOLID principles
5050
- Follows OWASP security guidance
5151

52+
## Internal Helper Methods
53+
- `#freezeResult(result)` - Freezes individual values or arrays when immutable mode is enabled
54+
- `#fromCache(cached)` - Returns cloned result (non-immutable) or frozen result (immutable) from cache
55+
- `#toCache(cacheKey, records)` - Stores results in cache if enabled
56+
- `#getIndexKeysFrom(arg, source, getValueFn)` - Generates composite index keys using a getter callback
57+
- `#getNestedValue(obj, path)` - Retrieves nested values using dot notation (e.g., `user.profile.city`)
58+
- `#sortKeys(a, b)` - Type-aware comparator: strings use `localeCompare`, numbers use subtraction, mixed types coerced to string
59+
- `#merge(a, b, override)` - Deep merges values, skips prototype pollution keys (`__proto__`, `constructor`, `prototype`)
60+
- `#invalidateCache()` - Clears cache if enabled and not in batch mode
61+
- `#getCacheKey(domain, ...args)` - Generates SHA-256 hash cache key from arguments
62+
- `#clone(arg)` - Deep clones values via `structuredClone` or JSON fallback
63+
64+
## Immutable Mode
65+
- Use `#freezeResult()` to return frozen data instead of inline `Object.freeze()` checks
66+
- Applies to: `get()`, `find()`, `filter()`, `limit()`, `map()`, `toArray()`, `sort()`, `sortBy()`, `search()`, `where()`
67+
- Cached results are also frozen/cloned via `#fromCache()` when read
68+
- `#merge()` preserves version snapshots by freezing cloned originals before versioning
69+
70+
## Caching
71+
- Cache is opt-in via `cache: true` in constructor
72+
- Automatically invalidates on all write operations (set, delete, clear, reindex, setMany, deleteMany, override)
73+
- Does NOT invalidate during batch operations (`#inBatch = true`), only after batch completes
74+
- `search()` and `where()` use multi-domain cache keys: `search_HASH` / `where_HASH`
75+
- LRU cache size defaults to 1000 (`CACHE_SIZE_DEFAULT`)
76+
77+
## Indexing
78+
- `#index` config array persists across `clear()` - `clearIndexes` is separate
79+
- After `clear()`, `#indexes` Map is emptied but `#index` config remains
80+
- Setting new records after `clear()` triggers lazy re-creation of index Maps in `#setIndex()`
81+
- Composite indexes use delimiter (default `|`) to join sorted field names
82+
- Use `#getIndexKeysFrom()` with appropriate getter callbacks for data objects vs where clauses
83+
84+
## Batch Operations
85+
- `setMany()` and `deleteMany()` set `#inBatch = true` to skip indexing during individual operations
86+
- Indexing is deferred to `reindex()` call after batch completes
87+
- Nested batch calls (calling setMany within setMany) throw errors
88+
- `#inBatch` affects versioning too - versions are not saved during batch operations
89+
90+
## Test Strategy Tips
91+
- Hard-to-reach branches often involve state transitions (e.g., `clear()``set()` on same store)
92+
- Coverage gaps frequently involve conditional logic on `#inBatch`, `#immutable`, or `#cacheEnabled`
93+
- Add tests that explicitly combine features (immutable + indexing + batch + caching)
94+
5295
## Important Notes
5396
- The `immutable` option freezes data for immutability
5497
- Indexes improve query performance for `find()` and `where()` operations
@@ -62,3 +105,10 @@ npm run benchmark # Run benchmarks
62105
- Cache invalidates on all write operations but preserves statistics
63106
- `search()` and `where()` are async methods - use `await` when calling
64107
- Cache statistics persist for the lifetime of the Haro instance
108+
109+
## Refactoring Patterns
110+
- Centralize immutable freezing in `#freezeResult()` - never use inline `Object.freeze()` for return values
111+
- Centralize cache read patterns in `#fromCache(cached)` and cache write in `#toCache(key, records)`
112+
- Consolidate index key generation in `#getIndexKeysFrom()` using a getter callback parameter
113+
- When DRY requires a single function to handle both data objects and where clauses, use a `getValueFn` callback parameter
114+
- Always run `npm test` after structural refactors - helper extraction changes call signatures

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ For complete API documentation with all methods and examples, see [API.md](https
425425
- **Core Methods**: `set()`, `get()`, `delete()`, `has()`, `clear()`
426426
- **Query Methods**: `find()`, `where()`, `search()`, `filter()`, `sortBy()`, `limit()`
427427
- **Batch Operations**: `setMany()`, `deleteMany()`
428-
- **Utility Methods**: `clone()`, `merge()`, `toArray()`, `dump()`, `override()`
428+
- **Utility Methods**: `toArray()`, `dump()`, `override()`
429429
- **Properties**: `size`, `registry`
430430

431431
## Troubleshooting

coverage.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
ℹ --------------------------------------------------------------
55
ℹ src | | | |
66
ℹ constants.js | 100.00 | 100.00 | 100.00 |
7-
ℹ haro.js | 100.00 | 97.92 | 98.73 |
7+
ℹ haro.js | 100.00 | 97.86 | 97.62 |
88
ℹ --------------------------------------------------------------
9-
ℹ all files | 100.00 | 97.93 | 98.73 |
9+
ℹ all files | 100.00 | 97.86 | 97.62 |
1010
ℹ --------------------------------------------------------------
1111
ℹ end of coverage report

dist/haro.cjs

Lines changed: 62 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,45 @@ class Haro {
256256
/* node:coverage ignore */ return JSON.parse(JSON.stringify(arg));
257257
}
258258

259+
/**
260+
* Freezes a result when immutable mode is enabled.
261+
* @param {Array|Object} result - Value to freeze
262+
* @returns {Array|Object} Frozen or unchanged result
263+
*/
264+
#freezeResult(result) {
265+
if (this.#immutable) {
266+
if (Array.isArray(result)) {
267+
for (let i = 0, len = result.length; i < len; i++) {
268+
Object.freeze(result[i]);
269+
}
270+
Object.freeze(result);
271+
} else {
272+
Object.freeze(result);
273+
}
274+
}
275+
return result;
276+
}
277+
278+
/**
279+
* Returns a cached result, cloned or frozen based on immutable mode.
280+
* @param {*} cached - Cached value
281+
* @returns {*} Cloned (non-immutable) or frozen (immutable) result
282+
*/
283+
#fromCache(cached) {
284+
return this.#immutable ? Object.freeze(cached) : this.#clone(cached);
285+
}
286+
287+
/**
288+
* Stores results in cache if enabled.
289+
* @param {*} cacheKey - Cache key
290+
* @param {Array} records - Result records to cache
291+
*/
292+
#toCache(cacheKey, records) {
293+
if (this.#cacheEnabled) {
294+
this.#cache.set(cacheKey, records);
295+
}
296+
}
297+
259298
/**
260299
* Deletes a record and removes it from all indexes.
261300
* @param {string} [key=STRING_EMPTY] - Key to delete
@@ -368,7 +407,7 @@ class Haro {
368407
const idx = this.#indexes.get(i);
369408
if (!idx) return;
370409
const values = i.includes(this.#delimiter)
371-
? this.#getIndexKeys(i, this.#delimiter, data)
410+
? this.#getIndexKeysFrom(i, data, (f, s) => this.#getNestedValue(s, f))
372411
: Array.isArray(this.#getNestedValue(data, i))
373412
? this.#getNestedValue(data, i)
374413
: [this.#getNestedValue(data, i)];
@@ -415,58 +454,20 @@ class Haro {
415454
}
416455

417456
/**
418-
* Generates index keys for composite indexes from data object.
457+
* Generates index keys from source object using a value getter function.
419458
* @param {string} arg - Composite index field names
420459
* @param {string} delimiter - Field delimiter
421-
* @param {Object} data - Data object
460+
* @param {Object} source - Source object (data or where clause)
461+
* @param {Function} getValueFn - Function(field, source) => value
422462
* @returns {string[]} Index keys
423463
*/
424-
#getIndexKeys(arg, delimiter, data) {
464+
#getIndexKeysFrom(arg, source, getValueFn) {
425465
const fields = arg.split(this.#delimiter).sort(this.#sortKeys);
426466
const result = [STRING_EMPTY];
427467
const fieldsLen = fields.length;
428468
for (let i = 0; i < fieldsLen; i++) {
429469
const field = fields[i];
430-
const fieldValue = this.#getNestedValue(data, field);
431-
const values = Array.isArray(fieldValue) ? fieldValue : [fieldValue];
432-
const newResult = [];
433-
const resultLen = result.length;
434-
const valuesLen = values.length;
435-
for (let j = 0; j < resultLen; j++) {
436-
const existing = result[j];
437-
for (let k = 0; k < valuesLen; k++) {
438-
const value = values[k];
439-
const newKey = i === 0 ? value : `${existing}${this.#delimiter}${value}`;
440-
newResult.push(newKey);
441-
}
442-
}
443-
result.length = 0;
444-
result.push(...newResult);
445-
}
446-
return result;
447-
}
448-
449-
/**
450-
* Generates index keys for where object (handles both dot notation and direct access).
451-
* @param {string} arg - Composite index field names
452-
* @param {string} delimiter - Field delimiter
453-
* @param {Object} where - Where object
454-
* @returns {string[]} Index keys
455-
*/
456-
#getIndexKeysForWhere(arg, delimiter, where) {
457-
const fields = arg.split(this.#delimiter).sort(this.#sortKeys);
458-
const result = [STRING_EMPTY];
459-
const fieldsLen = fields.length;
460-
for (let i = 0; i < fieldsLen; i++) {
461-
const field = fields[i];
462-
// Check if field exists directly in where object first (for dot notation keys)
463-
let fieldValue;
464-
if (field in where) {
465-
fieldValue = where[field];
466-
/* node:coverage ignore next 4 */
467-
} else {
468-
fieldValue = this.#getNestedValue(where, field);
469-
}
470+
const fieldValue = getValueFn(field, source);
470471
const values = Array.isArray(fieldValue) ? fieldValue : [fieldValue];
471472
const newResult = [];
472473
const resultLen = result.length;
@@ -512,7 +513,9 @@ class Haro {
512513

513514
const index = this.#indexes.get(compositeKey);
514515
if (index) {
515-
const keys = this.#getIndexKeysForWhere(compositeKey, this.#delimiter, where);
516+
const keys = this.#getIndexKeysFrom(compositeKey, where, (f, s) =>
517+
f in s ? s[f] : this.#getNestedValue(s, f),
518+
);
516519
const keysLen = keys.length;
517520
for (let i = 0; i < keysLen; i++) {
518521
const v = keys[i];
@@ -525,11 +528,7 @@ class Haro {
525528
}
526529
}
527530

528-
const records = Array.from(result, (i) => this.get(i));
529-
if (this.#immutable) {
530-
return Object.freeze(records);
531-
}
532-
return records;
531+
return this.#freezeResult(Array.from(result, (i) => this.get(i)));
533532
}
534533

535534
/**
@@ -550,10 +549,7 @@ class Haro {
550549
result.push(value);
551550
}
552551
});
553-
if (this.#immutable) {
554-
return Object.freeze(result);
555-
}
556-
return result;
552+
return this.#freezeResult(result);
557553
}
558554

559555
/**
@@ -587,10 +583,7 @@ class Haro {
587583
if (result === undefined) {
588584
return null;
589585
}
590-
if (this.#immutable) {
591-
return Object.freeze(result);
592-
}
593-
return result;
586+
return this.#freezeResult(result);
594587
}
595588

596589
/**
@@ -629,12 +622,7 @@ class Haro {
629622
if (typeof max !== STRING_NUMBER) {
630623
throw new Error(STRING_ERROR_LIMIT_MAX_TYPE);
631624
}
632-
let result = this.registry.slice(offset, offset + max).map((i) => this.get(i));
633-
if (this.#immutable) {
634-
result = Object.freeze(result);
635-
}
636-
637-
return result;
625+
return this.#freezeResult(this.registry.slice(offset, offset + max).map((i) => this.get(i)));
638626
}
639627

640628
/**
@@ -651,11 +639,7 @@ class Haro {
651639
}
652640
let result = [];
653641
this.forEach((value, key) => result.push(fn(value, key)));
654-
if (this.#immutable) {
655-
result = Object.freeze(result);
656-
}
657-
658-
return result;
642+
return this.#freezeResult(result);
659643
}
660644

661645
/**
@@ -762,7 +746,7 @@ class Haro {
762746
cacheKey = await this.#getCacheKey(STRING_CACHE_DOMAIN_SEARCH, value, index);
763747
const cached = this.#cache.get(cacheKey);
764748
if (cached !== undefined) {
765-
return this.#immutable ? Object.freeze(cached) : this.#clone(cached);
749+
return this.#fromCache(cached);
766750
}
767751
}
768752

@@ -799,14 +783,8 @@ class Haro {
799783
}
800784
const records = Array.from(result, (key) => this.get(key));
801785

802-
if (this.#cacheEnabled) {
803-
this.#cache.set(cacheKey, records);
804-
}
805-
806-
if (this.#immutable) {
807-
return Object.freeze(records);
808-
}
809-
return records;
786+
this.#toCache(cacheKey, records);
787+
return this.#freezeResult(records);
810788
}
811789

812790
/**
@@ -876,7 +854,7 @@ class Haro {
876854
this.#indexes.set(field, idx);
877855
}
878856
const values = field.includes(this.#delimiter)
879-
? this.#getIndexKeys(field, this.#delimiter, data)
857+
? this.#getIndexKeysFrom(field, data, (f, s) => this.#getNestedValue(s, f))
880858
: Array.isArray(this.#getNestedValue(data, field))
881859
? this.#getNestedValue(data, field)
882860
: [this.#getNestedValue(data, field)];
@@ -959,10 +937,7 @@ class Haro {
959937
return mapped;
960938
});
961939

962-
if (this.#immutable) {
963-
return Object.freeze(result);
964-
}
965-
return result;
940+
return this.#freezeResult(result);
966941
}
967942

968943
/**
@@ -974,11 +949,7 @@ class Haro {
974949
toArray() {
975950
const result = Array.from(this.#data.values());
976951
if (this.#immutable) {
977-
const resultLen = result.length;
978-
for (let i = 0; i < resultLen; i++) {
979-
Object.freeze(result[i]);
980-
}
981-
Object.freeze(result);
952+
return this.#freezeResult(result);
982953
}
983954

984955
return result;
@@ -1058,7 +1029,7 @@ class Haro {
10581029
cacheKey = await this.#getCacheKey(STRING_CACHE_DOMAIN_WHERE, predicate, op);
10591030
const cached = this.#cache.get(cacheKey);
10601031
if (cached !== undefined) {
1061-
return this.#immutable ? Object.freeze(cached) : this.#clone(cached);
1032+
return this.#fromCache(cached);
10621033
}
10631034
}
10641035

@@ -1130,14 +1101,8 @@ class Haro {
11301101
}
11311102
}
11321103

1133-
if (this.#cacheEnabled) {
1134-
this.#cache.set(cacheKey, results);
1135-
}
1136-
1137-
if (this.#immutable) {
1138-
return Object.freeze(results);
1139-
}
1140-
return results;
1104+
this.#toCache(cacheKey, results);
1105+
return this.#freezeResult(results);
11411106
}
11421107
}
11431108
}

0 commit comments

Comments
 (0)