@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
JavaScript
// 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