UNPKG

@gullerya/object-observer

Version:

object-observer utility provides simple means to (deeply) observe specified object/array changes; implemented via native Proxy; changes delivered in a synchronous way

758 lines (736 loc) 24 kB
// src/constants.ts var oMetaKey = Symbol.for("object-observer-meta-key-0"); var INSERT = "insert"; var UPDATE = "update"; var DELETE = "delete"; var REVERSE = "reverse"; var SHUFFLE = "shuffle"; // src/model/change.ts var Change = class { #type; #path; #value; #oldValue; #object; #pathAsString; constructor(type, path, value, oldValue, object) { this.#type = type; this.#path = path; this.#value = value; this.#oldValue = oldValue; this.#object = object; } get type() { return this.#type; } get path() { return this.#path; } get value() { return this.#value; } get oldValue() { return this.#oldValue; } get object() { return this.#object; } /** * lazily computed string representation of the path */ get pathAsString() { if (this.#pathAsString === void 0) { this.#pathAsString = this.#path.join("."); } return this.#pathAsString; } }; // src/changes-processors/validators.ts var Validator = class _Validator { static #privateCtorKey = Symbol("ValidatorPrivateConstructorKey"); #validate; constructor(privateCtorKey, fn) { if (privateCtorKey !== _Validator.#privateCtorKey) { throw new Error("Validator class cannot be instantiated directly; use provided factory methods"); } this.#validate = fn; } get validate() { return this.#validate; } static custom(fn) { if (typeof fn !== "function") { throw new Error("custom Validator requires a function as argument"); } return new _Validator(_Validator.#privateCtorKey, fn); } }; // src/observables/methods/delete-property.ts function proxiedDeleteProperty(target, key) { const prevValue = target[key]; const prospective = [new Change(DELETE, [key], void 0, prevValue, this.proxy)]; runValidators(this, prospective); delete target[key]; const oldValue = detachIfObservable(prevValue); const changes = [new Change(DELETE, [key], void 0, oldValue, this.proxy)]; callObservers(this, changes); return true; } // src/observables/methods/set.ts function proxiedSet(target, key, value) { const prevValue = target[key]; if (value !== prevValue) { const prospective = prevValue === void 0 ? [new Change(INSERT, [key], value, void 0, this.proxy)] : [new Change(UPDATE, [key], value, prevValue, this.proxy)]; runValidators(this, prospective); const newValue = getObservedOf(value, key, this); target[key] = newValue; const oldValue = detachIfObservable(prevValue); const changes = oldValue === void 0 ? [new Change(INSERT, [key], newValue, void 0, this.proxy)] : [new Change(UPDATE, [key], newValue, oldValue, this.proxy)]; callObservers(this, changes); } return true; } // src/observables/abstract-base.ts var validObservableOptionKeys = { async: 1, validators: 1 }; var ObservableBase = class { #parent; ownKey; #target; #proxy; // eslint-disable-next-line no-unused-private-class-members #revoke; #async = false; batches = /* @__PURE__ */ new Map(); set; deleteProperty; #validators = []; #observers = []; constructor(properties) { this.set = proxiedSet; this.deleteProperty = proxiedDeleteProperty; const { target, parent, ownKey, options, visited = /* @__PURE__ */ new Set() } = properties; if (parent && ownKey !== void 0) { this.#parent = parent; this.ownKey = ownKey; } else { this.#parent = null; this.ownKey = null; } visited.add(target); this.#target = this.observedGraphProcessor(target, this, visited); visited.delete(target); const revocableProxy = Proxy.revocable(this.#target, this); this.#proxy = revocableProxy.proxy; this.#revoke = revocableProxy.revoke; this.#processOptions(options); } detach() { this.#parent = null; return this.#target; } get parent() { return this.#parent; } get target() { return this.#target; } get proxy() { return this.#proxy; } get async() { return this.#async; } get validators() { return this.#validators; } get observers() { return this.#observers; } #processOptions(options) { if (!options) { return; } if (typeof options !== "object") { throw new Error(`Observable options if/when provided, MAY only be an object, got '${options}'`); } const invalidOptions = Object.keys(options).filter((option) => !(option in validObservableOptionKeys)); if (invalidOptions.length) { throw new Error(`'${invalidOptions.join(", ")}' is/are not a valid Observable option/s`); } this.#async = Boolean(options.async); if (options.validators !== void 0) { if (!Array.isArray(options.validators) || options.validators.length === 0) { throw new Error('"validators" option, if/when provided, MUST be a non-empty array of Validator instances'); } for (const v of options.validators) { if (!(v instanceof Validator)) { throw new Error('"validators" option, if/when provided, MUST be a non-empty array of Validator instances'); } } this.#validators.push(...options.validators); } } }; // src/observables/methods/copy-within.ts function proxiedCopyWithin(dest, start, end) { const oMeta = this[oMetaKey]; const target = oMeta.target; const tarLen = target.length; dest = dest < 0 ? Math.max(tarLen + dest, 0) : dest; start = start === void 0 ? 0 : start < 0 ? Math.max(tarLen + start, 0) : Math.min(start, tarLen); end = end === void 0 ? tarLen : end < 0 ? Math.max(tarLen + end, 0) : Math.min(end, tarLen); const len = Math.min(end - start, tarLen - dest); if (dest < tarLen && dest !== start && len > 0) { const prev = target.slice(0); const changes = []; target.copyWithin(dest, start, end); for (let i = dest; i < dest + len; i++) { let nItem = target[i]; if (nItem && typeof nItem === "object") { nItem = getObservedOf(nItem, i, oMeta); target[i] = nItem; } const oItem = detachIfObservable(prev[i]); if (typeof nItem !== "object" && nItem === oItem) { continue; } changes.push(new Change(UPDATE, [i], nItem, oItem, this)); } callObservers(oMeta, changes); } return this; } // src/observables/methods/fill.ts function proxiedFill(filVal, start, end) { const oMeta = this[oMetaKey]; const target = oMeta.target; const changes = []; const tarLen = target.length; const prev = target.slice(0); start = start === void 0 ? 0 : start < 0 ? Math.max(tarLen + start, 0) : Math.min(start, tarLen); end = end === void 0 ? tarLen : end < 0 ? Math.max(tarLen + end, 0) : Math.min(end, tarLen); if (start < tarLen && end > start) { target.fill(filVal, start, end); for (let i = start; i < end; i++) { target[i] = getObservedOf(target[i], i, oMeta); if (i in prev) { const oldValue = detachIfObservable(prev[i]); changes.push(new Change(UPDATE, [i], target[i], oldValue, this)); } else { changes.push(new Change(INSERT, [i], target[i], void 0, this)); } } callObservers(oMeta, changes); } return this; } // src/observables/methods/pop.ts function proxiedPop() { const oMeta = this[oMetaKey]; const target = oMeta.target; const poppedIndex = target.length - 1; const prevValue = target[poppedIndex]; const prospective = [new Change(DELETE, [poppedIndex], void 0, prevValue, this)]; runValidators(oMeta, prospective); const popResult = detachIfObservable(target.pop()); const changes = [new Change(DELETE, [poppedIndex], void 0, popResult, this)]; callObservers(oMeta, changes); return popResult; } // src/observables/methods/push.ts function proxiedPush(...pushItems) { const oMeta = this[oMetaKey]; const target = oMeta.target; const pushLen = pushItems.length; const initialLength = target.length; const prospective = new Array(pushLen); for (let i = 0; i < pushLen; i++) { prospective[i] = new Change(INSERT, [initialLength + i], pushItems[i], void 0, this); } runValidators(oMeta, prospective); const pushContent = new Array(pushLen); for (let i = 0; i < pushLen; i++) { pushContent[i] = getObservedOf(pushItems[i], initialLength + i, oMeta); } const pushResult = Reflect.apply(target.push, target, pushContent); const changes = new Array(pushLen); for (let i = 0; i < pushLen; i++) { changes[i] = new Change(INSERT, [initialLength + i], target[initialLength + i], void 0, this); } callObservers(oMeta, changes); return pushResult; } // src/observables/methods/reverse.ts function proxiedReverse() { const oMeta = this[oMetaKey]; const target = oMeta.target; const changes = [new Change(REVERSE, [], void 0, void 0, this)]; runValidators(oMeta, changes); target.reverse(); reindexObservableChildren(target); callObservers(oMeta, changes); return this; } // src/observables/methods/shift.ts function proxiedShift() { const oMeta = this[oMetaKey]; const target = oMeta.target; const prevValue = target[0]; const prospective = [new Change(DELETE, [0], void 0, prevValue, this)]; runValidators(oMeta, prospective); const shiftResult = detachIfObservable(target.shift()); reindexObservableChildren(target); const changes = [new Change(DELETE, [0], void 0, shiftResult, this)]; callObservers(oMeta, changes); return shiftResult; } // src/observables/methods/sort.ts function proxiedSort(comparator) { const oMeta = this[oMetaKey]; const target = oMeta.target; const changes = [new Change(SHUFFLE, [], void 0, void 0, this)]; runValidators(oMeta, changes); target.sort(comparator); reindexObservableChildren(target); callObservers(oMeta, changes); return this; } // src/observables/methods/splice.ts function proxiedSplice(...spliceItems) { const oMeta = this[oMetaKey]; const target = oMeta.target; const splLen = spliceItems.length; const spliceContent = new Array(splLen); const tarLen = target.length; for (let i = 0; i < splLen; i++) { spliceContent[i] = getObservedOf(spliceItems[i], i, oMeta); } const startIndex = splLen === 0 ? 0 : spliceContent[0] < 0 ? tarLen + spliceContent[0] : spliceContent[0]; const removed = splLen < 2 ? tarLen - startIndex : spliceContent[1]; const inserted = Math.max(splLen - 2, 0); const spliceResult = Reflect.apply(target.splice, target, spliceContent); reindexObservableChildren(target); for (let i = 0, l = spliceResult.length; i < l; i++) { spliceResult[i] = detachIfObservable(spliceResult[i]); } const changes = []; let index; for (index = 0; index < removed; index++) { if (index < inserted) { changes.push(new Change(UPDATE, [startIndex + index], target[startIndex + index], spliceResult[index], this)); } else { changes.push(new Change(DELETE, [startIndex + index], void 0, spliceResult[index], this)); } } for (; index < inserted; index++) { changes.push(new Change(INSERT, [startIndex + index], target[startIndex + index], void 0, this)); } callObservers(oMeta, changes); return spliceResult; } // src/observables/methods/unshift.ts function proxiedUnshift(...unshiftItems) { const oMeta = this[oMetaKey]; const target = oMeta.target; const unshiftLen = unshiftItems.length; const prospective = new Array(unshiftLen); for (let i = 0; i < unshiftLen; i++) { prospective[i] = new Change(INSERT, [i], unshiftItems[i], void 0, this); } runValidators(oMeta, prospective); const unshiftContent = new Array(unshiftLen); for (let i = 0; i < unshiftLen; i++) { unshiftContent[i] = getObservedOf(unshiftItems[i], i, oMeta); } const unshiftResult = Reflect.apply(target.unshift, target, unshiftContent); reindexObservableChildren(target); const changes = new Array(unshiftLen); for (let i = 0; i < unshiftLen; i++) { changes[i] = new Change(INSERT, [i], target[i], void 0, this); } callObservers(oMeta, changes); return unshiftResult; } // src/observables/array.ts var proxiedArrayMethods = { copyWithin: proxiedCopyWithin, fill: proxiedFill, pop: proxiedPop, push: proxiedPush, reverse: proxiedReverse, shift: proxiedShift, sort: proxiedSort, splice: proxiedSplice, unshift: proxiedUnshift }; var ObservableArray = class extends ObservableBase { get(target, key) { return proxiedArrayMethods[key] || target[key]; } observedGraphProcessor(source, observableWrapper, visited) { const arrayLength = source.length; const target = new Array(arrayLength); for (let i = 0; i < arrayLength; i++) { target[i] = getObservedOf(source[i], i, observableWrapper, visited); } target[oMetaKey] = observableWrapper; return target; } }; // src/observables/object.ts var ObservableObject = class extends ObservableBase { observedGraphProcessor(source, observableWrapper, visited) { const target = {}; target[oMetaKey] = observableWrapper; for (const key in source) { target[key] = getObservedOf(source[key], key, observableWrapper, visited); } return target; } }; // src/observables/methods/typed-set.ts function proxiedTypedArraySet(source, offset) { const oMeta = this[oMetaKey]; const target = oMeta.target; const souLen = source.length; offset = offset || 0; if (souLen > 0) { const prev = target.slice(offset, offset + souLen); const prospective = new Array(souLen); for (let i = 0; i < souLen; i++) { prospective[i] = new Change(UPDATE, [offset + i], source[i], prev[i], this); } runValidators(oMeta, prospective); target.set(source, offset); const changes = new Array(souLen); for (let i = 0; i < souLen; i++) { changes[i] = new Change(UPDATE, [offset + i], target[offset + i], prev[i], this); } callObservers(oMeta, changes); } } // src/observables/typed-array.ts var proxiedTypedArrayMethods = { copyWithin: proxiedCopyWithin, fill: proxiedFill, reverse: proxiedReverse, sort: proxiedSort, set: proxiedTypedArraySet }; var ObservableTypedArray = class extends ObservableBase { get(target, key) { return proxiedTypedArrayMethods[key] || target[key]; } observedGraphProcessor(source, observableWrapper) { source[oMetaKey] = observableWrapper; return source; } }; // src/observables/processors/proc-utils.ts function getObservedOf(item, key, parent, visited) { if (visited !== void 0 && visited.has(item)) { return null; } else if (typeof item !== "object" || item === null) { return item; } else if (Array.isArray(item)) { return new ObservableArray({ target: item, ownKey: key, parent, visited }).proxy; } else if (ArrayBuffer.isView(item)) { return new ObservableTypedArray({ target: item, ownKey: key, parent }).proxy; } else if (item instanceof Date) { return item; } else { return new ObservableObject({ target: item, ownKey: key, parent, visited }).proxy; } } function getObservableFromRoot(target, options = void 0) { if (!target || typeof target !== "object") { throw new Error("observable MAY ONLY be created from a non-null object"); } else if (target[oMetaKey]) { return target; } else if (Array.isArray(target)) { return new ObservableArray({ target, ownKey: null, parent: null, options }).proxy; } else if (ArrayBuffer.isView(target)) { return new ObservableTypedArray({ target, ownKey: null, parent: null, options }).proxy; } else if (target instanceof Date) { throw new Error(`${target} found to be one of a non-observable types`); } else { return new ObservableObject({ target, ownKey: null, parent: null, options }).proxy; } } function reindexObservableChildren(target, from = 0) { for (let i = from, l = target.length; i < l; i++) { const item = target[i]; if (item && typeof item === "object") { const childMeta = item[oMetaKey]; if (childMeta) { childMeta.ownKey = i; } } } } function detachIfObservable(value) { if (value && typeof value === "object") { const childMeta = value[oMetaKey]; if (childMeta) { return childMeta.detach(); } } return value; } function runValidators(oMeta, changes) { const prefix = []; let current = oMeta; while (current.parent) { prefix.unshift(current.ownKey); current = current.parent; } const validators = current.validators; if (validators.length === 0) { return; } const rooted = prefix.length === 0 ? changes : changes.map((c) => new Change(c.type, [...prefix, ...c.path], c.value, c.oldValue, c.object)); for (let i = 0, l = validators.length; i < l; i++) { validators[i].validate(rooted); } } function callObservers(oMeta, changes) { let currentObservable = oMeta; let isAsync, observers, target, options, relevantChanges, i; const l = changes.length; do { isAsync = currentObservable.async; observers = currentObservable.observers; i = observers.length; while (i--) { [target, options] = observers[i]; relevantChanges = filterChanges(options, changes); if (relevantChanges.length) { if (isAsync) { if (currentObservable.batches.size === 0) { queueMicrotask(callObserversFromMT.bind(currentObservable)); } let batch = currentObservable.batches.get(target); if (!batch) { batch = []; currentObservable.batches.set(target, batch); } batch.push(...relevantChanges); } else { callObserverSafe(target, relevantChanges); } } } const parent = currentObservable.parent; if (parent) { for (let j = 0; j < l; j++) { const change = changes[j]; changes[j] = new Change( change.type, [currentObservable.ownKey, ...change.path], change.value, change.oldValue, change.object ); } currentObservable = parent; } else { break; } } while (currentObservable); } function filterChanges(options, changes) { if (options === null || !options.filters) { return changes; } let result = changes; const filters = options.filters; for (let i = 0, l = filters.length; i < l && result.length; i++) { result = filters[i](result); } return result; } function callObserverSafe(listener, changes) { try { listener(changes); } catch (e) { console.error(`failed to notify listener ${listener} with ${changes}`, e); } } function callObserversFromMT() { const batches = this.batches; this.batches = /* @__PURE__ */ new Map(); for (const [listener, changes] of batches) { callObserverSafe(listener, changes); } } // src/changes-processors/filters.ts var Filter = class _Filter { static #privateCtorKey = Symbol("FilterPrivateConstructorKey"); #fn; constructor(privateCtorKey, fn) { if (privateCtorKey !== _Filter.#privateCtorKey) { throw new Error("Filter class cannot be instantiated directly; use provided factory methods"); } this.#fn = fn; } get fn() { return this.#fn; } static custom(fn) { if (typeof fn !== "function") { throw new Error("custom Filter requires a function as argument"); } return new _Filter(_Filter.#privateCtorKey, fn); } static exactPaths(paths) { if (!Array.isArray(paths) || paths.length === 0) { throw new Error("exactPaths Filter requires a non-empty array as argument"); } const pathsSet = new Set(paths); return new _Filter( _Filter.#privateCtorKey, (changes) => changes.filter((change) => pathsSet.has(change.pathAsString)) ); } static pathsStartWith(prefix) { if (typeof prefix !== "string" || prefix === "") { throw new Error("pathsStartWith Filter requires a non-empty string as argument"); } return new _Filter( _Filter.#privateCtorKey, (changes) => changes.filter((change) => change.pathAsString.startsWith(prefix)) ); } // direct children of the given path; an empty string represents the root. // REVERSE/SHUFFLE happening at the path itself are also included // (they are semantically mutations of the container's direct children). static directChildrenOf(path) { if (typeof path !== "string") { throw new Error("directChildrenOf Filter requires a string as argument (MAY be empty)"); } const segments = path.split(".").filter(Boolean); const depth = segments.length; const prefix = segments.join("."); return new _Filter( _Filter.#privateCtorKey, (changes) => changes.filter((change) => { const cp = change.path; const pl = cp.length; if (pl === depth + 1) { for (let i = 0; i < depth; i++) { if (cp[i] !== segments[i]) { return false; } } return true; } if (pl === depth && (change.type === REVERSE || change.type === SHUFFLE)) { return change.pathAsString === prefix; } return false; }) ); } }; // src/object-observer.ts var processObserveOptions = (options) => { if (!options || typeof options !== "object") { return null; } const result = {}; const invalidOptions = []; for (const [optName, optVal] of Object.entries(options)) { if (optName === "filters") { if (!Array.isArray(optVal) || optVal.length === 0) { throw new Error('"filters" option, if/when provided, MUST be a non-empty array of Filter instances'); } const fns = new Array(optVal.length); for (let i = 0; i < optVal.length; i++) { const f = optVal[i]; if (!(f instanceof Filter)) { throw new Error('"filters" option, if/when provided, MUST be a non-empty array of Filter instances'); } fns[i] = f.fn; } result.filters = fns; } else { invalidOptions.push(optName); } } if (invalidOptions.length) { throw new Error(`'${invalidOptions.join(", ")}' is/are not a valid observer option/s`); } return Object.keys(result).length === 0 ? null : result; }; var Observable = Object.freeze({ from: getObservableFromRoot, isObservable: (input) => { return !!(input && input[oMetaKey]); }, observe: (observable, observer, options) => { if (!Observable.isObservable(observable)) { throw new Error(`invalid observable parameter`); } if (typeof observer !== "function") { throw new Error(`observer MUST be a function, got '${observer}'`); } const observers = observable[oMetaKey].observers; if (!observers.some((o) => o[0] === observer)) { observers.push([observer, processObserveOptions(options)]); } else { console.warn("observer may be bound to an observable only once; will NOT rebind"); } }, unobserve: (observable, ...observers) => { if (!Observable.isObservable(observable)) { throw new Error(`invalid observable parameter`); } const existingObs = observable[oMetaKey].observers; let el = existingObs.length; if (!el) { return; } if (!observers.length) { existingObs.splice(0); return; } while (el) { const i = observers.indexOf(existingObs[--el][0]); if (i >= 0) { existingObs.splice(el, 1); } } } }); var ObjectObserver = class { #observer; #targets; constructor(observer) { this.#observer = observer; this.#targets = /* @__PURE__ */ new Set(); Object.freeze(this); } observe(target, options) { const r = Observable.from(target); Observable.observe(r, this.#observer, options); this.#targets.add(r); return r; } unobserve(target) { Observable.unobserve(target, this.#observer); this.#targets.delete(target); } disconnect() { for (const t of this.#targets) { Observable.unobserve(t, this.#observer); } this.#targets.clear(); } }; export { Filter, ObjectObserver, Observable, Validator }; //# sourceMappingURL=object-observer.js.map