UNPKG

mithril-sync

Version:

A powerful utility to flatten, diff, rebuild, and sync deeply nested JavaScript data structures — inspired by magic, forged in code.

518 lines (467 loc) 16.7 kB
/** * MithrilSync provides tools for deep object flattening, tracking, diffing, * mutation, and live synchronization with support for Maps, Sets, Arrays, and Objects. */ class MithrilSync { /** * @param {Object} object - The object to initialize and track. * @example * const sync = new MithrilSync({ user: { name: 'Alice' } }); */ constructor(object = {}) { this.original = structuredClone(object); this.entries = MithrilSync.flatten(object); this._watcher = null; }; /** * Flattens a nested object structure into a list of entries. * Supports Map, Set, Array, and plain objects. * * @param {Object} obj - The object to flatten. * @param {Array} prefix - The current path (used internally). * @returns {Array} List of flattened entries. * @example * MithrilSync.flatten({ a: { b: 1 } }); * // => [{ path: ['a', 'b'], dotPath: 'a.b', value: 1, kind: 'number' }] */ static flatten(obj = {}, prefix = []) { const results = []; const stack = [{ current: obj, path: prefix, kind: Array.isArray(obj) ? 'array' : typeof obj }]; while (stack.length) { const { current, path, kind } = stack.pop(); // Include the current node itself (as a container entry), but skip root (empty path) if (path.length) { results.push({ path: [...path], dotPath: path.join('.'), key: path[path.length - 1], value: current, kind: Array.isArray(current) ? 'array' : current instanceof Map ? 'map' : current instanceof Set ? 'set' : typeof current }); } // Recurse into child structures if (current instanceof Map) { for (const [key, value] of current.entries()) { stack.push({ current: value, path: [...path, key], kind: 'map' }); } } else if (current instanceof Set) { let index = 0; for (const value of current.values()) { stack.push({ current: value, path: [...path, index++], kind: 'set' }); } } else if (Array.isArray(current)) { current.forEach((value, index) => { stack.push({ current: value, path: [...path, index], kind: 'array' }); }); } else if (typeof current === 'object' && current !== null) { for (const [key, value] of Object.entries(current)) { stack.push({ current: value, path: [...path, key], kind: 'object' }); } } } return results; } /** * Reconstructs an object from flattened entries. * * @param {Array} entries - The flattened entries to rebuild. * @returns {Object} The nested object. * @example * MithrilSync.rebuild([{ path: ['a', 'b'], value: 1 }]); * // => { a: { b: 1 } } */ static rebuild(entries = []) { const result = {}; for (const { path, value } of entries) { let ref = result; for (let i = 0; i < path.length; i++) { const key = path[i]; if (i === path.length - 1) { ref[key] = value; } else { if (!(key in ref)) ref[key] = typeof path[i + 1] === 'number' ? [] : {}; ref = ref[key]; } } } return result; }; /** * Deeply merges two objects recursively. * * @param {Object} target * @param {Object} source * @returns {Object} The merged object. * @example * MithrilSync.merge({ a: 1 }, { b: 2 }); * // => { a: 1, b: 2 } */ static merge(target = {}, source = {}) { for (const [key, value] of Object.entries(source)) { if ( typeof value === 'object' && value !== null && typeof target[key] === 'object' && target[key] !== null ) { target[key] = MithrilSync.merge(target[key], value); } else { target[key] = value; } } return target; }; /** * Compares two sets of entries and returns a list of changes. * * @param {Array} oldEntries * @param {Array} newEntries * @param {Object} options * @param {boolean} [options.deepCompare=false] * @param {boolean} [options.strictTypes=false] * @returns {Array} List of changes (added, removed, modified). * @example * MithrilSync.watch(oldFlat, newFlat); */ static watch(oldEntries = [], newEntries = [], options = {}) { const { deepCompare = false, strictTypes = false } = options; const changes = []; const toMap = (entries) => Object.fromEntries(entries.map(({ dotPath, value }) => [dotPath, value])); const oldMap = toMap(oldEntries); const newMap = toMap(newEntries); const allPaths = new Set([...Object.keys(oldMap), ...Object.keys(newMap)]); for (const path of allPaths) { if (!(path in oldMap)) { changes.push({ type: 'added', path, value: newMap[path] }); } else if (!(path in newMap)) { changes.push({ type: 'removed', path, oldValue: oldMap[path] }); } else { const isDifferent = deepCompare ? JSON.stringify(oldMap[path]) !== JSON.stringify(newMap[path]) : (strictTypes ? oldMap[path] !== newMap[path] : oldMap[path] != newMap[path]); if (isDifferent) { changes.push({ type: 'modified', path, oldValue: oldMap[path], newValue: newMap[path], }); } } } return changes; }; /** * Gets a value from an object using dotPath. * * @param {Object} obj * @param {string} dotPath * @returns {*} Value at the path or undefined. * @example * MithrilSync.get({ a: { b: 1 } }, 'a.b'); * // => 1 */ static get(obj, dotPath) { return dotPath.split('.').reduce((o, k) => o?.[k], obj); }; /** * Sets a value in an object using dotPath. * * @param {Object} obj * @param {string} dotPath * @param {*} value * @example * const obj = {}; * MithrilSync.set(obj, 'a.b', 42); * // obj => { a: { b: 42 } } */ static set(obj, dotPath, value) { const keys = dotPath.split('.'); let ref = obj; keys.slice(0, -1).forEach((k) => { if (!(k in ref)) ref[k] = {}; ref = ref[k]; }); ref[keys.at(-1)] = value; }; /** * Applies a list of changes to a target object. * * @param {Object} target * @param {Array} changes * @returns {Object} Modified target */ static applyChanges(target = {}, changes = []) { for (const change of changes) { const keys = change.path.split('.'); let ref = target; for (let i = 0; i < keys.length - 1; i++) { if (!(keys[i] in ref)) ref[keys[i]] = {}; ref = ref[keys[i]]; } const lastKey = keys.at(-1); if (change.type === 'removed') { delete ref[lastKey]; } else { ref[lastKey] = change.value ?? change.newValue; } } return target; }; /** * Builds a new object from a base and a diff. * * @param {Object} base * @param {Array} diff * @returns {Object} New object with applied diff */ static fromDiff(base = {}, diff = []) { return this.applyChanges(structuredClone(base), diff); }; /** * Manually updates the internal entry list. * * @param {Array} entries - New flattened entries. */ syncEntries(entries = []) { this.entries = [...entries]; }; /** * Returns the original object used in the constructor. * * @returns {Object} */ getOriginal() { return structuredClone(this.original); }; /** * Returns the current flattened entries. * * @returns {Array} */ getFlat() { return structuredClone(this.entries); }; /** * Rebuilds the full object from flattened entries. * * @returns {Object} */ rebuild() { return MithrilSync.rebuild(this.entries); }; /** * Updates or adds a single entry by dotPath. * * @param {string} dotPath * @param {*} newValue * @example * sync.updateEntry('user.name', 'Bob'); */ updateEntry(dotPath, newValue) { const index = this.entries.findIndex((e) => e.dotPath === dotPath); if (index !== -1) { this.entries[index].value = newValue; } else { const path = dotPath.split('.').map(k => isNaN(k) ? k : +k); this.entries.push({ path, dotPath, value: newValue }); } }; /** * Removes a flattened entry by dotPath. * * @param {string} dotPath */ removeEntry(dotPath) { this.entries = this.entries.filter((e) => e.dotPath !== dotPath); }; /** * Compares the current rebuilt object with the original. * * @param {Object} options * @returns {Array} List of detected changes. */ getChanges(options = {}) { const currentFlat = MithrilSync.flatten(this.rebuild()); return MithrilSync.watch(MithrilSync.flatten(this.original), currentFlat, options); }; /** * Merges the current original object with another one. * * @param {Object} source * @example * sync.mergeWith({ user: { email: 'a@b.com' } }); */ mergeWith(source = {}) { const merged = MithrilSync.merge(this.getOriginal(), source); this.entries = MithrilSync.flatten(merged); }; /** * Reverts changes using a diff (like undo). * * @param {Array} diff * @example * sync.revertChanges(diff); */ revertChanges(diff = []) { const reversed = diff.map(change => { if (change.type === 'added') { return { type: 'removed', path: change.path }; } else if (change.type === 'removed') { return { type: 'added', path: change.path, value: change.oldValue }; } else if (change.type === 'modified') { return { type: 'modified', path: change.path, oldValue: change.newValue, newValue: change.oldValue }; } }); const reverted = MithrilSync.applyChanges(this.rebuild(), reversed); this.entries = MithrilSync.flatten(reverted); }; /** * Starts watching for live changes and invokes a callback when they occur. * * @param {Function} callback * @param {number} interval - Polling interval in ms. * @param {Object} options - Options passed to watch() * @example * sync.watchLive((changes) => console.log(changes), 1000); */ watchLive(callback, interval = 500, options = {}) { clearInterval(this._watcher); let previousFlat = MithrilSync.flatten(this.rebuild()); this._watcher = setInterval(() => { const currentFlat = MithrilSync.flatten(this.rebuild()); const changes = MithrilSync.watch(previousFlat, currentFlat, options); if (changes.length) { previousFlat = currentFlat; callback(changes); } }, interval); }; /** * Stops the live change watcher. */ stopWatch() { clearInterval(this._watcher); this._watcher = null; }; /** * Rebuilds the object from only selected entries. * * @param {Function} filterFn - A function to filter entries. * @returns {Object} * @example * sync.toTree(entry => entry.dotPath.startsWith('user')); */ toTree(filterFn = () => true) { const filtered = this.entries.filter(filterFn); return MithrilSync.rebuild(filtered); }; /** * Searches through flattened entries with advanced filtering options. * * @param {Object} options * @param {string|number} options.target * @param {Array} [options.fallbacks] * @param {boolean} [options.matchKeys=true] * @param {boolean} [options.matchValues=true] * @param {boolean} [options.caseInsensitive=false] * @param {boolean} [options.useRegex=false] * @param {boolean} [options.findAll=false] * @param {Function|null} [options.mutate=null] * @param {Array} [options.onlyTypes=[]] * @param {'full'|'dotPaths'|'entriesOnly'} [options.returnFormat='full'] * @param {boolean} [options.includeNull=true] * @param {boolean} [options.includeUndefined=true] * @param {boolean} [options.includeEmptyString=true] * @returns {Object} { matched: boolean, results: array } * @example * sync.find({ target: 'name', matchKeys: true }); */ find({ target = '', fallbacks = [], matchKeys = true, matchValues = true, caseInsensitive = false, useRegex = false, findAll = false, mutate = null, onlyTypes = [], returnFormat = 'full', includeNull = true, includeUndefined = true, includeEmptyString = true } = {}) { const targets = []; if (typeof target === 'string' && target.trim()) { targets.push(target); } if (Array.isArray(fallbacks)) { targets.push(...fallbacks.filter(t => typeof t === 'string' && t.trim())); } if (targets.length === 0) { return { matched: false, results: [] }; } const normalize = (str) => caseInsensitive ? str.toLowerCase() : str; const seenPaths = new Set(); const results = []; for (const search of targets) { const targetNormalized = normalize(search); const regex = useRegex ? new RegExp(search, caseInsensitive ? 'i' : '') : null; const isMatch = (input) => { if (typeof input !== 'string') input = String(input); const testStr = normalize(input); return useRegex ? regex.test(input) : testStr === targetNormalized; }; for (const entry of this.entries) { const key = entry.path.at(-1); const val = entry.value; if (onlyTypes.length > 0 && !onlyTypes.includes(typeof val)) continue; if (!includeNull && val === null) continue; if (!includeUndefined && val === undefined) continue; if (!includeEmptyString && val === '') continue; let matched = false; if (matchKeys && isMatch(String(key))) { matched = true; } if (!matched && matchValues && isMatch(val)) { matched = true; } if (matched && !seenPaths.has(entry.dotPath)) { seenPaths.add(entry.dotPath); if (mutate) mutate(entry); switch (returnFormat) { case 'dotPaths': results.push(entry.dotPath); break; case 'entriesOnly': results.push(entry); break; default: results.push({ key, value: val, path: entry.path, dotPath: entry.dotPath }); } if (!findAll) { return { matched: true, results }; } } } } return { matched: results.length > 0, results }; }; }; module.exports = MithrilSync;