UNPKG

haro

Version:

Haro is a modern immutable DataStore

1,030 lines (939 loc) 33.1 kB
/** * haro * * @copyright 2025 Jason Mulligan <jason.mulligan@avoidwork.com> * @license BSD-3-Clause * @version 16.0.0 */ 'use strict'; var crypto = require('crypto'); // String constants - Single characters and symbols const STRING_COMMA = ","; const STRING_EMPTY = ""; const STRING_PIPE = "|"; const STRING_DOUBLE_PIPE = "||"; const STRING_DOUBLE_AND = "&&"; // String constants - Operation and type names const STRING_ID = "id"; const STRING_DEL = "del"; const STRING_FUNCTION = "function"; const STRING_INDEXES = "indexes"; const STRING_OBJECT = "object"; const STRING_RECORDS = "records"; const STRING_REGISTRY = "registry"; const STRING_SET = "set"; const STRING_SIZE = "size"; const STRING_STRING = "string"; const STRING_NUMBER = "number"; // String constants - Error messages const STRING_INVALID_FIELD = "Invalid field"; const STRING_INVALID_FUNCTION = "Invalid function"; const STRING_INVALID_TYPE = "Invalid type"; const STRING_RECORD_NOT_FOUND = "Record not found"; // Integer constants const INT_0 = 0; /** * Haro is a modern immutable DataStore for collections of records with indexing, * versioning, and batch operations support. It provides a Map-like interface * with advanced querying capabilities through indexes. * @class * @example * const store = new Haro({ * index: ['name', 'age'], * key: 'id', * versioning: true * }); * * store.set(null, {name: 'John', age: 30}); * const results = store.find({name: 'John'}); */ class Haro { /** * Creates a new Haro instance with specified configuration * @param {Object} [config={}] - Configuration object for the store * @param {string} [config.delimiter=STRING_PIPE] - Delimiter for composite indexes (default: '|') * @param {string} [config.id] - Unique identifier for this instance (auto-generated if not provided) * @param {boolean} [config.immutable=false] - Return frozen/immutable objects for data safety * @param {string[]} [config.index=[]] - Array of field names to create indexes for * @param {string} [config.key=STRING_ID] - Primary key field name used for record identification * @param {boolean} [config.versioning=false] - Enable versioning to track record changes * @constructor * @example * const store = new Haro({ * index: ['name', 'email', 'name|department'], * key: 'userId', * versioning: true, * immutable: true * }); */ constructor ({delimiter = STRING_PIPE, id = this.uuid(), immutable = false, index = [], key = STRING_ID, versioning = false} = {}) { this.data = new Map(); this.delimiter = delimiter; this.id = id; this.immutable = immutable; this.index = Array.isArray(index) ? [...index] : []; this.indexes = new Map(); this.key = key; this.versions = new Map(); this.versioning = versioning; Object.defineProperty(this, STRING_REGISTRY, { enumerable: true, get: () => Array.from(this.data.keys()) }); Object.defineProperty(this, STRING_SIZE, { enumerable: true, get: () => this.data.size }); return this.reindex(); } /** * Performs batch operations on multiple records for efficient bulk processing * @param {Array<Object>} args - Array of records to process * @param {string} [type=STRING_SET] - Type of operation: 'set' for upsert, 'del' for delete * @returns {Array<Object>} Array of results from the batch operation * @throws {Error} Throws error if individual operations fail during batch processing * @example * const results = store.batch([ * {id: 1, name: 'John'}, * {id: 2, name: 'Jane'} * ], 'set'); */ batch (args, type = STRING_SET) { const fn = type === STRING_DEL ? i => this.delete(i, true) : i => this.set(null, i, true, true); return this.onbatch(this.beforeBatch(args, type).map(fn), type); } /** * Lifecycle hook executed before batch operations for custom preprocessing * @param {Array<Object>} arg - Arguments passed to batch operation * @param {string} [type=STRING_EMPTY] - Type of batch operation ('set' or 'del') * @returns {Array<Object>} The arguments array (possibly modified) to be processed */ beforeBatch (arg, type = STRING_EMPTY) { // eslint-disable-line no-unused-vars // Hook for custom logic before batch; override in subclass if needed return arg; } /** * Lifecycle hook executed before clear operation for custom preprocessing * @returns {void} Override this method in subclasses to implement custom logic * @example * class MyStore extends Haro { * beforeClear() { * this.backup = this.toArray(); * } * } */ beforeClear () { // Hook for custom logic before clear; override in subclass if needed } /** * Lifecycle hook executed before delete operation for custom preprocessing * @param {string} [key=STRING_EMPTY] - Key of record to delete * @param {boolean} [batch=false] - Whether this is part of a batch operation * @returns {void} Override this method in subclasses to implement custom logic */ beforeDelete (key = STRING_EMPTY, batch = false) { // eslint-disable-line no-unused-vars // Hook for custom logic before delete; override in subclass if needed } /** * Lifecycle hook executed before set operation for custom preprocessing * @param {string} [key=STRING_EMPTY] - Key of record to set * @param {Object} [data={}] - Record data being set * @param {boolean} [batch=false] - Whether this is part of a batch operation * @param {boolean} [override=false] - Whether to override existing data * @returns {void} Override this method in subclasses to implement custom logic */ beforeSet (key = STRING_EMPTY, data = {}, batch = false, override = false) { // eslint-disable-line no-unused-vars // Hook for custom logic before set; override in subclass if needed } /** * Removes all records, indexes, and versions from the store * @returns {Haro} This instance for method chaining * @example * store.clear(); * console.log(store.size); // 0 */ clear () { this.beforeClear(); this.data.clear(); this.indexes.clear(); this.versions.clear(); this.reindex().onclear(); return this; } /** * Creates a deep clone of the given value, handling objects, arrays, and primitives * @param {*} arg - Value to clone (any type) * @returns {*} Deep clone of the argument * @example * const original = {name: 'John', tags: ['user', 'admin']}; * const cloned = store.clone(original); * cloned.tags.push('new'); // original.tags is unchanged */ clone (arg) { return structuredClone(arg); } /** * Deletes a record from the store and removes it from all indexes * @param {string} [key=STRING_EMPTY] - Key of record to delete * @param {boolean} [batch=false] - Whether this is part of a batch operation * @returns {void} * @throws {Error} Throws error if record with the specified key is not found * @example * store.delete('user123'); * // Throws error if 'user123' doesn't exist */ delete (key = STRING_EMPTY, batch = false) { if (!this.data.has(key)) { throw new Error(STRING_RECORD_NOT_FOUND); } const og = this.get(key, true); this.beforeDelete(key, batch); this.deleteIndex(key, og); this.data.delete(key); this.ondelete(key, batch); if (this.versioning) { this.versions.delete(key); } } /** * Internal method to remove entries from indexes for a deleted record * @param {string} key - Key of record being deleted * @param {Object} data - Data of record being deleted * @returns {Haro} This instance for method chaining */ deleteIndex (key, data) { this.index.forEach(i => { const idx = this.indexes.get(i); if (!idx) return; const values = i.includes(this.delimiter) ? this.indexKeys(i, this.delimiter, data) : Array.isArray(data[i]) ? data[i] : [data[i]]; this.each(values, value => { if (idx.has(value)) { const o = idx.get(value); o.delete(key); if (o.size === INT_0) { idx.delete(value); } } }); }); return this; } /** * Exports complete store data or indexes for persistence or debugging * @param {string} [type=STRING_RECORDS] - Type of data to export: 'records' or 'indexes' * @returns {Array<Array>} Array of [key, value] pairs for records, or serialized index structure * @example * const records = store.dump('records'); * const indexes = store.dump('indexes'); */ dump (type = STRING_RECORDS) { let result; if (type === STRING_RECORDS) { result = Array.from(this.entries()); } else { result = Array.from(this.indexes).map(i => { i[1] = Array.from(i[1]).map(ii => { ii[1] = Array.from(ii[1]); return ii; }); return i; }); } return result; } /** * Utility method to iterate over an array with a callback function * @param {Array<*>} [arr=[]] - Array to iterate over * @param {Function} fn - Function to call for each element (element, index) * @returns {Array<*>} The original array for method chaining * @example * store.each([1, 2, 3], (item, index) => console.log(item, index)); */ each (arr = [], fn) { const len = arr.length; for (let i = 0; i < len; i++) { fn(arr[i], i); } return arr; } /** * Returns an iterator of [key, value] pairs for each record in the store * @returns {Iterator<Array<string|Object>>} Iterator of [key, value] pairs * @example * for (const [key, value] of store.entries()) { * console.log(key, value); * } */ entries () { return this.data.entries(); } /** * Finds records matching the specified criteria using indexes for optimal performance * @param {Object} [where={}] - Object with field-value pairs to match against * @param {boolean} [raw=false] - Whether to return raw data without processing * @returns {Array<Object>} Array of matching records (frozen if immutable mode) * @example * const users = store.find({department: 'engineering', active: true}); * const admins = store.find({role: 'admin'}); */ find (where = {}, raw = false) { const key = Object.keys(where).sort(this.sortKeys).join(this.delimiter); const index = this.indexes.get(key) ?? new Map(); let result = []; if (index.size > 0) { const keys = this.indexKeys(key, this.delimiter, where); result = Array.from(keys.reduce((a, v) => { if (index.has(v)) { index.get(v).forEach(k => a.add(k)); } return a; }, new Set())).map(i => this.get(i, raw)); } if (!raw && this.immutable) { result = Object.freeze(result); } return result; } /** * Filters records using a predicate function, similar to Array.filter * @param {Function} fn - Predicate function to test each record (record, key, store) * @param {boolean} [raw=false] - Whether to return raw data without processing * @returns {Array<Object>} Array of records that pass the predicate test * @throws {Error} Throws error if fn is not a function * @example * const adults = store.filter(record => record.age >= 18); * const recent = store.filter(record => record.created > Date.now() - 86400000); */ filter (fn, raw = false) { if (typeof fn !== STRING_FUNCTION) { throw new Error(STRING_INVALID_FUNCTION); } let result = this.reduce((a, v) => { if (fn(v)) { a.push(v); } return a; }, []); if (!raw) { result = result.map(i => this.list(i)); if (this.immutable) { result = Object.freeze(result); } } return result; } /** * Executes a function for each record in the store, similar to Array.forEach * @param {Function} fn - Function to execute for each record (value, key) * @param {*} [ctx] - Context object to use as 'this' when executing the function * @returns {Haro} This instance for method chaining * @example * store.forEach((record, key) => { * console.log(`${key}: ${record.name}`); * }); */ forEach (fn, ctx = this) { this.data.forEach((value, key) => { if (this.immutable) { value = this.clone(value); } fn.call(ctx, value, key); }, this); return this; } /** * Creates a frozen array from the given arguments for immutable data handling * @param {...*} args - Arguments to freeze into an array * @returns {Array<*>} Frozen array containing frozen arguments * @example * const frozen = store.freeze(obj1, obj2, obj3); * // Returns Object.freeze([Object.freeze(obj1), Object.freeze(obj2), Object.freeze(obj3)]) */ freeze (...args) { return Object.freeze(args.map(i => Object.freeze(i))); } /** * Retrieves a record by its key * @param {string} key - Key of record to retrieve * @param {boolean} [raw=false] - Whether to return raw data (true) or processed/frozen data (false) * @returns {Object|null} The record if found, null if not found * @example * const user = store.get('user123'); * const rawUser = store.get('user123', true); */ get (key, raw = false) { let result = this.data.get(key) ?? null; if (result !== null && !raw) { result = this.list(result); if (this.immutable) { result = Object.freeze(result); } } return result; } /** * Checks if a record with the specified key exists in the store * @param {string} key - Key to check for existence * @returns {boolean} True if record exists, false otherwise * @example * if (store.has('user123')) { * console.log('User exists'); * } */ has (key) { return this.data.has(key); } /** * Generates index keys for composite indexes from data values * @param {string} [arg=STRING_EMPTY] - Composite index field names joined by delimiter * @param {string} [delimiter=STRING_PIPE] - Delimiter used in composite index * @param {Object} [data={}] - Data object to extract field values from * @returns {string[]} Array of generated index keys * @example * // For index 'name|department' with data {name: 'John', department: 'IT'} * const keys = store.indexKeys('name|department', '|', data); * // Returns ['John|IT'] */ indexKeys (arg = STRING_EMPTY, delimiter = STRING_PIPE, data = {}) { const fields = arg.split(delimiter).sort(this.sortKeys); const fieldsLen = fields.length; let result = [""]; for (let i = 0; i < fieldsLen; i++) { const field = fields[i]; const values = Array.isArray(data[field]) ? data[field] : [data[field]]; const newResult = []; const resultLen = result.length; const valuesLen = values.length; for (let j = 0; j < resultLen; j++) { for (let k = 0; k < valuesLen; k++) { const newKey = i === 0 ? values[k] : `${result[j]}${delimiter}${values[k]}`; newResult.push(newKey); } } result = newResult; } return result; } /** * Returns an iterator of all keys in the store * @returns {Iterator<string>} Iterator of record keys * @example * for (const key of store.keys()) { * console.log(key); * } */ keys () { return this.data.keys(); } /** * Returns a limited subset of records with offset support for pagination * @param {number} [offset=INT_0] - Number of records to skip from the beginning * @param {number} [max=INT_0] - Maximum number of records to return * @param {boolean} [raw=false] - Whether to return raw data without processing * @returns {Array<Object>} Array of records within the specified range * @example * const page1 = store.limit(0, 10); // First 10 records * const page2 = store.limit(10, 10); // Next 10 records */ limit (offset = INT_0, max = INT_0, raw = false) { let result = this.registry.slice(offset, offset + max).map(i => this.get(i, raw)); if (!raw && this.immutable) { result = Object.freeze(result); } return result; } /** * Converts a record into a [key, value] pair array format * @param {Object} arg - Record object to convert to list format * @returns {Array<*>} Array containing [key, record] where key is extracted from record's key field * @example * const record = {id: 'user123', name: 'John', age: 30}; * const pair = store.list(record); // ['user123', {id: 'user123', name: 'John', age: 30}] */ list (arg) { const result = [arg[this.key], arg]; return this.immutable ? this.freeze(...result) : result; } /** * Transforms all records using a mapping function, similar to Array.map * @param {Function} fn - Function to transform each record (record, key) * @param {boolean} [raw=false] - Whether to return raw data without processing * @returns {Array<*>} Array of transformed results * @throws {Error} Throws error if fn is not a function * @example * const names = store.map(record => record.name); * const summaries = store.map(record => ({id: record.id, name: record.name})); */ map (fn, raw = false) { if (typeof fn !== STRING_FUNCTION) { throw new Error(STRING_INVALID_FUNCTION); } let result = []; this.forEach((value, key) => result.push(fn(value, key))); if (!raw) { result = result.map(i => this.list(i)); if (this.immutable) { result = Object.freeze(result); } } return result; } /** * Merges two values together with support for arrays and objects * @param {*} a - First value (target) * @param {*} b - Second value (source) * @param {boolean} [override=false] - Whether to override arrays instead of concatenating * @returns {*} Merged result * @example * const merged = store.merge({a: 1}, {b: 2}); // {a: 1, b: 2} * const arrays = store.merge([1, 2], [3, 4]); // [1, 2, 3, 4] */ merge (a, b, override = false) { if (Array.isArray(a) && Array.isArray(b)) { a = override ? b : a.concat(b); } else if (typeof a === STRING_OBJECT && a !== null && typeof b === STRING_OBJECT && b !== null) { this.each(Object.keys(b), i => { a[i] = this.merge(a[i], b[i], override); }); } else { a = b; } return a; } /** * Lifecycle hook executed after batch operations for custom postprocessing * @param {Array<Object>} arg - Result of batch operation * @param {string} [type=STRING_EMPTY] - Type of batch operation that was performed * @returns {Array<Object>} Modified result (override this method to implement custom logic) */ onbatch (arg, type = STRING_EMPTY) { // eslint-disable-line no-unused-vars return arg; } /** * Lifecycle hook executed after clear operation for custom postprocessing * @returns {void} Override this method in subclasses to implement custom logic * @example * class MyStore extends Haro { * onclear() { * console.log('Store cleared'); * } * } */ onclear () { // Hook for custom logic after clear; override in subclass if needed } /** * Lifecycle hook executed after delete operation for custom postprocessing * @param {string} [key=STRING_EMPTY] - Key of deleted record * @param {boolean} [batch=false] - Whether this was part of a batch operation * @returns {void} Override this method in subclasses to implement custom logic */ ondelete (key = STRING_EMPTY, batch = false) { // eslint-disable-line no-unused-vars // Hook for custom logic after delete; override in subclass if needed } /** * Lifecycle hook executed after override operation for custom postprocessing * @param {string} [type=STRING_EMPTY] - Type of override operation that was performed * @returns {void} Override this method in subclasses to implement custom logic */ onoverride (type = STRING_EMPTY) { // eslint-disable-line no-unused-vars // Hook for custom logic after override; override in subclass if needed } /** * Lifecycle hook executed after set operation for custom postprocessing * @param {Object} [arg={}] - Record that was set * @param {boolean} [batch=false] - Whether this was part of a batch operation * @returns {void} Override this method in subclasses to implement custom logic */ onset (arg = {}, batch = false) { // eslint-disable-line no-unused-vars // Hook for custom logic after set; override in subclass if needed } /** * Replaces all store data or indexes with new data for bulk operations * @param {Array<Array>} data - Data to replace with (format depends on type) * @param {string} [type=STRING_RECORDS] - Type of data: 'records' or 'indexes' * @returns {boolean} True if operation succeeded * @throws {Error} Throws error if type is invalid * @example * const records = [['key1', {name: 'John'}], ['key2', {name: 'Jane'}]]; * store.override(records, 'records'); */ override (data, type = STRING_RECORDS) { const result = true; if (type === STRING_INDEXES) { this.indexes = new Map(data.map(i => [i[0], new Map(i[1].map(ii => [ii[0], new Set(ii[1])]))])); } else if (type === STRING_RECORDS) { this.indexes.clear(); this.data = new Map(data); } else { throw new Error(STRING_INVALID_TYPE); } this.onoverride(type); return result; } /** * Reduces all records to a single value using a reducer function * @param {Function} fn - Reducer function (accumulator, value, key, store) * @param {*} [accumulator] - Initial accumulator value * @returns {*} Final reduced value * @example * const totalAge = store.reduce((sum, record) => sum + record.age, 0); * const names = store.reduce((acc, record) => acc.concat(record.name), []); */ reduce (fn, accumulator = []) { let a = accumulator; this.forEach((v, k) => { a = fn(a, v, k, this); }, this); return a; } /** * Rebuilds indexes for specified fields or all fields for data consistency * @param {string|string[]} [index] - Specific index field(s) to rebuild, or all if not specified * @returns {Haro} This instance for method chaining * @example * store.reindex(); // Rebuild all indexes * store.reindex('name'); // Rebuild only name index * store.reindex(['name', 'email']); // Rebuild name and email indexes */ reindex (index) { const indices = index ? [index] : this.index; if (index && this.index.includes(index) === false) { this.index.push(index); } this.each(indices, i => this.indexes.set(i, new Map())); this.forEach((data, key) => this.each(indices, i => this.setIndex(key, data, i))); return this; } /** * Searches for records containing a value across specified indexes * @param {*} value - Value to search for (string, function, or RegExp) * @param {string|string[]} [index] - Index(es) to search in, or all if not specified * @param {boolean} [raw=false] - Whether to return raw data without processing * @returns {Array<Object>} Array of matching records * @example * const results = store.search('john'); // Search all indexes * const nameResults = store.search('john', 'name'); // Search only name index * const regexResults = store.search(/^admin/, 'role'); // Regex search */ search (value, index, raw = false) { const result = new Set(); // Use Set for unique keys const fn = typeof value === STRING_FUNCTION; const rgex = value && typeof value.test === STRING_FUNCTION; if (!value) return this.immutable ? this.freeze() : []; const indices = index ? Array.isArray(index) ? index : [index] : this.index; for (const i of indices) { const idx = this.indexes.get(i); if (idx) { for (const [lkey, lset] of idx) { let match = false; if (fn) { match = value(lkey, i); } else if (rgex) { match = value.test(Array.isArray(lkey) ? lkey.join(STRING_COMMA) : lkey); } else { match = lkey === value; } if (match) { for (const key of lset) { if (this.data.has(key)) { result.add(key); } } } } } } let records = Array.from(result).map(key => this.get(key, raw)); if (!raw && this.immutable) { records = Object.freeze(records); } return records; } /** * Sets or updates a record in the store with automatic indexing * @param {string|null} [key=null] - Key for the record, or null to use record's key field * @param {Object} [data={}] - Record data to set * @param {boolean} [batch=false] - Whether this is part of a batch operation * @param {boolean} [override=false] - Whether to override existing data instead of merging * @returns {Object} The stored record (frozen if immutable mode) * @example * const user = store.set(null, {name: 'John', age: 30}); // Auto-generate key * const updated = store.set('user123', {age: 31}); // Update existing record */ set (key = null, data = {}, batch = false, override = false) { if (key === null) { key = data[this.key] ?? this.uuid(); } let x = {...data, [this.key]: key}; this.beforeSet(key, x, batch, override); if (!this.data.has(key)) { if (this.versioning) { this.versions.set(key, new Set()); } } else { const og = this.get(key, true); this.deleteIndex(key, og); if (this.versioning) { this.versions.get(key).add(Object.freeze(this.clone(og))); } if (!override) { x = this.merge(this.clone(og), x); } } this.data.set(key, x); this.setIndex(key, x, null); const result = this.get(key); this.onset(result, batch); return result; } /** * Internal method to add entries to indexes for a record * @param {string} key - Key of record being indexed * @param {Object} data - Data of record being indexed * @param {string|null} indice - Specific index to update, or null for all * @returns {Haro} This instance for method chaining */ setIndex (key, data, indice) { this.each(indice === null ? this.index : [indice], i => { let idx = this.indexes.get(i); if (!idx) { idx = new Map(); this.indexes.set(i, idx); } const fn = c => { if (!idx.has(c)) { idx.set(c, new Set()); } idx.get(c).add(key); }; if (i.includes(this.delimiter)) { this.each(this.indexKeys(i, this.delimiter, data), fn); } else { this.each(Array.isArray(data[i]) ? data[i] : [data[i]], fn); } }); return this; } /** * Sorts all records using a comparator function * @param {Function} fn - Comparator function for sorting (a, b) => number * @param {boolean} [frozen=false] - Whether to return frozen records * @returns {Array<Object>} Sorted array of records * @example * const sorted = store.sort((a, b) => a.age - b.age); // Sort by age * const names = store.sort((a, b) => a.name.localeCompare(b.name)); // Sort by name */ sort (fn, frozen = false) { const dataSize = this.data.size; let result = this.limit(INT_0, dataSize, true).sort(fn); if (frozen) { result = this.freeze(...result); } return result; } /** * Comparator function for sorting keys with type-aware comparison logic * @param {*} a - First value to compare * @param {*} b - Second value to compare * @returns {number} Negative number if a < b, positive if a > b, zero if equal * @example * const keys = ['name', 'age', 'email']; * keys.sort(store.sortKeys); // Alphabetical sort * * const mixed = [10, '5', 'abc', 3]; * mixed.sort(store.sortKeys); // Type-aware sort: numbers first, then strings */ sortKeys (a, b) { // Handle string comparison if (typeof a === STRING_STRING && typeof b === STRING_STRING) { return a.localeCompare(b); } // Handle numeric comparison if (typeof a === STRING_NUMBER && typeof b === STRING_NUMBER) { return a - b; } // Handle mixed types or other types by converting to string return String(a).localeCompare(String(b)); } /** * Sorts records by a specific indexed field in ascending order * @param {string} [index=STRING_EMPTY] - Index field name to sort by * @param {boolean} [raw=false] - Whether to return raw data without processing * @returns {Array<Object>} Array of records sorted by the specified field * @throws {Error} Throws error if index field is empty or invalid * @example * const byAge = store.sortBy('age'); * const byName = store.sortBy('name'); */ sortBy (index = STRING_EMPTY, raw = false) { if (index === STRING_EMPTY) { throw new Error(STRING_INVALID_FIELD); } let result = []; const keys = []; if (this.indexes.has(index) === false) { this.reindex(index); } const lindex = this.indexes.get(index); lindex.forEach((idx, key) => keys.push(key)); this.each(keys.sort(this.sortKeys), i => lindex.get(i).forEach(key => result.push(this.get(key, raw)))); if (this.immutable) { result = Object.freeze(result); } return result; } /** * Converts all store data to a plain array of records * @returns {Array<Object>} Array containing all records in the store * @example * const allRecords = store.toArray(); * console.log(`Store contains ${allRecords.length} records`); */ toArray () { const result = Array.from(this.data.values()); if (this.immutable) { this.each(result, i => Object.freeze(i)); Object.freeze(result); } return result; } /** * Generates a RFC4122 v4 UUID for record identification * @returns {string} UUID string in standard format * @example * const id = store.uuid(); // "f47ac10b-58cc-4372-a567-0e02b2c3d479" */ uuid () { return crypto.randomUUID(); } /** * Returns an iterator of all values in the store * @returns {Iterator<Object>} Iterator of record values * @example * for (const record of store.values()) { * console.log(record.name); * } */ values () { return this.data.values(); } /** * Internal helper method for predicate matching with support for arrays and regex * @param {Object} record - Record to test against predicate * @param {Object} predicate - Predicate object with field-value pairs * @param {string} op - Operator for array matching ('||' for OR, '&&' for AND) * @returns {boolean} True if record matches predicate criteria */ matchesPredicate (record, predicate, op) { const keys = Object.keys(predicate); return keys.every(key => { const pred = predicate[key]; const val = 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)); } else { return op === STRING_DOUBLE_AND ? pred.every(p => val === p) : pred.some(p => val === p); } } else if (pred instanceof RegExp) { if (Array.isArray(val)) { return op === STRING_DOUBLE_AND ? val.every(v => pred.test(v)) : val.some(v => pred.test(v)); } else { return pred.test(val); } } else if (Array.isArray(val)) { return val.includes(pred); } else { return val === pred; } }); } /** * Advanced filtering with predicate logic supporting AND/OR operations on arrays * @param {Object} [predicate={}] - Object with field-value pairs for filtering * @param {string} [op=STRING_DOUBLE_PIPE] - Operator for array matching ('||' for OR, '&&' for AND) * @returns {Array<Object>} Array of records matching the predicate criteria * @example * // Find records with tags containing 'admin' OR 'user' * const users = store.where({tags: ['admin', 'user']}, '||'); * * // Find records with ALL specified tags * const powerUsers = store.where({tags: ['admin', 'power']}, '&&'); * * // Regex matching * const emails = store.where({email: /^admin@/}); */ where (predicate = {}, op = STRING_DOUBLE_PIPE) { const keys = this.index.filter(i => i in predicate); if (keys.length === 0) return []; // Try to use indexes for better performance const indexedKeys = keys.filter(k => this.indexes.has(k)); if (indexedKeys.length > 0) { // Use index-based filtering for better performance let candidateKeys = new Set(); let first = true; for (const key of indexedKeys) { const pred = predicate[key]; const idx = this.indexes.get(key); const matchingKeys = new Set(); if (Array.isArray(pred)) { for (const p of pred) { if (idx.has(p)) { for (const k of idx.get(p)) { matchingKeys.add(k); } } } } else if (idx.has(pred)) { for (const k of idx.get(pred)) { matchingKeys.add(k); } } if (first) { candidateKeys = matchingKeys; first = false; } else { // AND operation across different fields candidateKeys = new Set([...candidateKeys].filter(k => matchingKeys.has(k))); } } // Filter candidates with full predicate logic const results = []; for (const key of candidateKeys) { const record = this.get(key, true); if (this.matchesPredicate(record, predicate, op)) { results.push(this.immutable ? this.get(key) : record); } } return this.immutable ? this.freeze(...results) : results; } // Fallback to full scan if no indexes available return this.filter(a => this.matchesPredicate(a, predicate, op)); } } /** * Factory function to create a new Haro instance with optional initial data * @param {Array<Object>|null} [data=null] - Initial data to populate the store * @param {Object} [config={}] - Configuration object passed to Haro constructor * @returns {Haro} New Haro instance configured and optionally populated * @example * const store = haro([ * {id: 1, name: 'John', age: 30}, * {id: 2, name: 'Jane', age: 25} * ], { * index: ['name', 'age'], * versioning: true * }); */ function haro (data = null, config = {}) { const obj = new Haro(config); if (Array.isArray(data)) { obj.batch(data, STRING_SET); } return obj; } exports.Haro = Haro; exports.haro = haro;