Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Haro is a modern immutable DataStore for collections of records with indexing, v
## Project Structure
- `src/haro.js` - Main Haro class and factory function
- `src/constants.js` - String and number constants
- `src/query-strategy.js` - Strategy pattern for predicate matching (`ValueMatcher`, `PredicateStrategy`)
- `tests/` - Unit tests using Node.js native test runner
- `dist/` - Built distribution files (generated)
- `types/haro.d.ts` - TypeScript definitions
Expand Down Expand Up @@ -54,9 +55,10 @@ npm run benchmark # Run benchmarks
- `#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
- `#getIndexValues(field, source)` - Extracts index values for a field, handling composite indexes and scalar/array fields. Centralizes deduplicated logic from `#setIndex()` and `#deleteIndex()`
- `#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`)
- `#merge(a, b)` - Deep merges values, skips prototype pollution keys (`__proto__`, `constructor`, `prototype`). `override` parameter removed
- `#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
Expand Down
19 changes: 10 additions & 9 deletions coverage.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
ℹ start of coverage report
ℹ --------------------------------------------------------------
ℹ file | line % | branch % | funcs % | uncovered lines
ℹ --------------------------------------------------------------
ℹ src | | | |
ℹ constants.js | 100.00 | 100.00 | 100.00 |
ℹ haro.js | 100.00 | 97.86 | 97.62 |
ℹ --------------------------------------------------------------
ℹ all files | 100.00 | 97.86 | 97.62 |
ℹ --------------------------------------------------------------
ℹ -------------------------------------------------------------------
ℹ file | line % | branch % | funcs % | uncovered lines
ℹ -------------------------------------------------------------------
ℹ src | | | |
ℹ constants.js | 100.00 | 100.00 | 100.00 |
ℹ haro.js | 100.00 | 98.48 | 98.77 |
ℹ query-strategy.js | 100.00 | 100.00 | 100.00 |
ℹ -------------------------------------------------------------------
ℹ all files | 100.00 | 98.61 | 98.90 |
ℹ -------------------------------------------------------------------
ℹ end of coverage report
205 changes: 136 additions & 69 deletions dist/haro.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const STRING_RECORD_NOT_FOUND = "Record not found";
// Integer constants
const INT_0 = 0;
const INT_2 = 2;
const INT_256 = 256;

// Number constants
const CACHE_SIZE_DEFAULT = 1000;
Expand Down Expand Up @@ -77,6 +78,98 @@ const PROP_VERSIONING = "versioning";
const PROP_VERSIONS = "versions";
const PROP_WARN_ON_FULL_SCAN = "warnOnFullScan";

/**
* Low-level value matcher for predicate matching.
* @class
*/
class ValueMatcher {
/**
* Matches a single value against a predicate.
* @param {*} val - Value to test
* @param {*} pred - Predicate value or RegExp
* @returns {boolean} Whether value matches predicate
*/
static match(val, pred) {
if (pred instanceof RegExp) return pred.test(val);
if (val instanceof RegExp) return val.test(pred);
return val === pred;
}
}

/**
* Predicate strategy for matching records against field predicates.
* Supports AND (every) and OR (some) logic for array predicates.
* @class
*/
class PredicateStrategy {
/**
* Creates a predicate strategy.
* @param {string} op - Operator: '||' (OR) or '&&' (AND)
*/
constructor(op) {
this.op = op;
}

/**
* Factory method to create a strategy instance.
* @param {string} op - Operator: '||' (OR) or '&&' (AND)
* @returns {PredicateStrategy}
*/
static of(op) {
return new PredicateStrategy(op);
}

/**
* Checks if a record matches a predicate object.
* @param {Object} record - Record to test
* @param {Object} predicate - Field-value predicate map
* @param {Function} getNestedValue - Function to retrieve nested values (record, path) => value
* @returns {boolean} Whether record matches all predicate fields
*/
matches(record, predicate, getNestedValue) {
return Object.keys(predicate).every((key) => {
return this.#matchField(record, key, predicate[key], getNestedValue);
});
}

/**
* Matches a single field's predicate against a record value.
* @param {Object} record - Record containing the value
* @param {string} key - Field path
* @param {*} pred - Predicate for the field (value, array of values, or RegExp)
* @param {Function} getNestedValue - Function to retrieve nested values
* @returns {boolean} Whether the field matches the predicate
*/
#matchField(record, key, pred, getNestedValue) {
const val = getNestedValue(record, key);

// Array predicate matching against record value
if (Array.isArray(pred)) {
if (Array.isArray(val)) {
return this.#matchArrayPred(pred, (p) => val.includes(p));
}
return this.#matchArrayPred(pred, (p) => val === p);
}

// Record field is an array, check if any element matches
if (Array.isArray(val)) {
return val.some((v) => ValueMatcher.match(v, pred));
}

return ValueMatcher.match(val, pred);
}

/**
* Applies the strategy's operator to an array of predicates.
* @param {Array} preds - Array of predicates
* @param {Function} fn - Matcher function (pred) => boolean
* @returns {boolean} Result of AND/OR evaluation
*/
#matchArrayPred(preds, fn) {
return this.op === STRING_DOUBLE_AND ? preds.every(fn) : preds.some(fn);
}
}

/**
* Haro is an immutable DataStore with indexing, versioning, and batch operations.
* Provides a Map-like interface with advanced querying capabilities.
Expand Down Expand Up @@ -396,6 +489,21 @@ class Haro {
return result;
}

/**
* Extracts index values for a field from a source object.
* Handles both composite indexes and scalar/array fields.
* @param {string} field - Field name or composite index path
* @param {Object} source - Source object
* @returns {Array} Array of index values
*/
#getIndexValues(field, source) {
if (field.includes(this.#delimiter)) {
return this.#getIndexKeysFrom(field, source, (f, s) => this.#getNestedValue(s, f));
}
const val = this.#getNestedValue(source, field);
return Array.isArray(val) ? val : [val];
}

/**
* Removes a record from all indexes.
* @param {string} key - Record key
Expand All @@ -406,11 +514,7 @@ class Haro {
this.#index.forEach((i) => {
const idx = this.#indexes.get(i);
if (!idx) return;
const values = i.includes(this.#delimiter)
? this.#getIndexKeysFrom(i, data, (f, s) => this.#getNestedValue(s, f))
: Array.isArray(this.#getNestedValue(data, i))
? this.#getNestedValue(data, i)
: [this.#getNestedValue(data, i)];
const values = this.#getIndexValues(i, data);
const len = values.length;
for (let j = 0; j < len; j++) {
const value = values[j];
Expand Down Expand Up @@ -649,9 +753,9 @@ class Haro {
* @param {boolean} [override=false] - Override arrays
* @returns {*} Merged result
*/
#merge(a, b, override = false) {
#merge(a, b) {
if (Array.isArray(a) && Array.isArray(b)) {
a = override ? b : a.concat(b);
a = a.concat(b);
} else if (
typeof a === STRING_OBJECT &&
a !== null &&
Expand All @@ -662,10 +766,7 @@ class Haro {
const keysLen = keys.length;
for (let i = 0; i < keysLen; i++) {
const key = keys[i];
if (key === STRING_PROTO || key === STRING_CONSTRUCTOR || key === STRING_PROTOTYPE) {
continue;
}
a[key] = this.#merge(a[key], b[key], override);
a[key] = this.#merge(a[key], b[key]);
}
} else {
a = b;
Expand Down Expand Up @@ -753,6 +854,9 @@ class Haro {
const result = new Set();
const fn = typeof value === STRING_FUNCTION;
const rgex = value && typeof value.test === STRING_FUNCTION;
if (rgex && value.source.length > INT_256) {
throw new Error(STRING_ERROR_SEARCH_VALUE);
}
const indices = index ? (Array.isArray(index) ? index : [index]) : this.#index;
const indicesLen = indices.length;

Expand Down Expand Up @@ -807,7 +911,11 @@ class Haro {
if (key === null) {
key = data[this.#key] ?? crypto$1.randomUUID();
}
let x = { ...data, [this.#key]: key };
const pollutionProps = new Set([STRING_PROTO, STRING_CONSTRUCTOR, STRING_PROTOTYPE]);
const safeData = Object.fromEntries(
Object.entries(data).filter(([k]) => !pollutionProps.has(k)),
);
let x = { ...safeData, [this.#key]: key };
if (!this.#data.has(key)) {
if (this.#versioning && !this.#inBatch) {
this.#versions.set(key, new Set());
Expand Down Expand Up @@ -853,11 +961,7 @@ class Haro {
idx = new Map();
this.#indexes.set(field, idx);
}
const values = field.includes(this.#delimiter)
? this.#getIndexKeysFrom(field, data, (f, s) => this.#getNestedValue(s, f))
: Array.isArray(this.#getNestedValue(data, field))
? this.#getNestedValue(data, field)
: [this.#getNestedValue(data, field)];
const values = this.#getIndexValues(field, data);
const valuesLen = values.length;
for (let j = 0; j < valuesLen; j++) {
const value = values[j];
Expand All @@ -878,17 +982,14 @@ class Haro {
* @example
* store.sort((a, b) => a.age - b.age);
*/
sort(fn, frozen = false) {
sort(fn) {
if (typeof fn !== STRING_FUNCTION) {
throw new Error(STRING_ERROR_SORT_FN_TYPE);
}
const dataSize = this.#data.size;
let result = this.limit(INT_0, dataSize).sort(fn);
if (frozen) {
result = Object.freeze(result);
}

return result;
const result = Array.from(this.#data.values())
.map((v) => (this.#immutable ? this.#clone(v) : v))
.sort(fn);
return this.#freezeResult(result);
}

/**
Expand Down Expand Up @@ -965,48 +1066,6 @@ class Haro {
return this.#data.values();
}

/**
* Matches a record against a predicate.
* @param {Object} record - Record to test
* @param {Object} predicate - Predicate object
* @param {string} op - Operator: '||' or '&&'
* @returns {boolean} True if matches
*/
#matchesPredicate(record, predicate, op) {
const keys = Object.keys(predicate);

return keys.every((key) => {
const pred = predicate[key];
// Use nested value extraction for dot notation paths
const val = this.#getNestedValue(record, key);
if (Array.isArray(pred)) {
if (Array.isArray(val)) {
return op === STRING_DOUBLE_AND
? pred.every((p) => val.includes(p))
: pred.some((p) => val.includes(p));
}
return op === STRING_DOUBLE_AND
? pred.every((p) => val === p)
: pred.some((p) => val === p);
}
if (Array.isArray(val)) {
return val.some((v) => {
if (pred instanceof RegExp) {
return pred.test(v);
}
if (v instanceof RegExp) {
return v.test(pred);
}
return v === pred;
});
}
if (pred instanceof RegExp) {
return pred.test(val);
}
return val === pred;
});
}

/**
* Filters records with predicate logic supporting AND/OR on arrays.
* @param {Object} [predicate={}] - Field-value pairs
Expand Down Expand Up @@ -1038,7 +1097,10 @@ class Haro {
if (this.#warnOnFullScan) {
console.warn("where(): performing full table scan - consider adding an index");
}
return this.filter((a) => this.#matchesPredicate(a, predicate, op));
const strategy = PredicateStrategy.of(op);
return this.filter((a) =>
strategy.matches(a, predicate, (r, k) => this.#getNestedValue(r, k)),
);
}

// Try to use indexes for better performance
Expand Down Expand Up @@ -1093,17 +1155,22 @@ class Haro {
}
}
// Filter candidates with full predicate logic
const strategy = PredicateStrategy.of(op);
const results = [];
for (const key of candidateKeys) {
const record = this.get(key);
if (this.#matchesPredicate(record, predicate, op)) {
if (strategy.matches(record, predicate, (r, k) => this.#getNestedValue(r, k))) {
results.push(record);
}
}

this.#toCache(cacheKey, results);
return this.#freezeResult(results);
}

// Indexed keys matched but no candidates found - return empty array
this.#toCache(cacheKey, []);
return this.#freezeResult([]);
}
}

Expand Down
Loading
Loading