UNPKG

acebase-core

Version:

Shared AceBase core components, no need to install manually

986 lines (985 loc) 60.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.OrderedCollectionProxy = exports.proxyAccess = exports.LiveDataProxy = void 0; const utils_1 = require("./utils"); const data_reference_1 = require("./data-reference"); const data_snapshot_1 = require("./data-snapshot"); const path_reference_1 = require("./path-reference"); const id_1 = require("./id"); const optional_observable_1 = require("./optional-observable"); const process_1 = require("./process"); const path_info_1 = require("./path-info"); const simple_event_emitter_1 = require("./simple-event-emitter"); class RelativeNodeTarget extends Array { static areEqual(t1, t2) { return t1.length === t2.length && t1.every((key, i) => t2[i] === key); } static isAncestor(ancestor, other) { return ancestor.length < other.length && ancestor.every((key, i) => other[i] === key); } static isDescendant(descendant, other) { return descendant.length > other.length && other.every((key, i) => descendant[i] === key); } } const isProxy = Symbol('isProxy'); class LiveDataProxy { /** * Creates a live data proxy for the given reference. The data of the reference's path will be loaded, and kept in-sync * with live data by listening for 'mutations' events. Any changes made to the value by the client will be synced back * to the database. * @param ref DataReference to create proxy for. * @param options proxy initialization options * be written to the database. */ static async create(ref, options) { var _a; ref = new data_reference_1.DataReference(ref.db, ref.path); // Use copy to prevent context pollution on original reference let cache, loaded = false; let latestCursor = options === null || options === void 0 ? void 0 : options.cursor; let proxy; const proxyId = id_1.ID.generate(); //ref.push().key; // let onMutationCallback: ProxyObserveMutationsCallback; // let onErrorCallback: ProxyObserveErrorCallback = err => { // console.error(err.message, err.details); // }; const clientSubscriptions = []; const clientEventEmitter = new simple_event_emitter_1.SimpleEventEmitter(); clientEventEmitter.on('cursor', (cursor) => latestCursor = cursor); clientEventEmitter.on('error', (err) => { console.error(err.message, err.details); }); const applyChange = (keys, newValue) => { // Make changes to cache if (keys.length === 0) { cache = newValue; return true; } const allowCreation = false; //cache === null; // If the proxy'd target did not exist upon load, we must allow it to be created now. if (allowCreation) { cache = typeof keys[0] === 'number' ? [] : {}; } let target = cache; const trailKeys = keys.slice(); while (trailKeys.length > 1) { const key = trailKeys.shift(); if (!(key in target)) { if (allowCreation) { target[key] = typeof key === 'number' ? [] : {}; } else { // Have we missed an event, or are local pending mutations creating this conflict? return false; // Do not proceed } } target = target[key]; } const prop = trailKeys.shift(); if (newValue === null) { // Remove it target instanceof Array ? target.splice(prop, 1) : delete target[prop]; } else { // Set or update it target[prop] = newValue; } return true; }; // Subscribe to mutations events on the target path const syncFallback = async () => { if (!loaded) { return; } await reload(); }; const subscription = ref.on('mutations', { syncFallback }).subscribe(async (snap) => { var _a; if (!loaded) { return; } const context = snap.context(); const isRemote = ((_a = context.acebase_proxy) === null || _a === void 0 ? void 0 : _a.id) !== proxyId; if (!isRemote) { return; // Update was done through this proxy, no need to update cache or trigger local value subscriptions } const mutations = snap.val(false); const proceed = mutations.every(mutation => { if (!applyChange(mutation.target, mutation.val)) { return false; } // if (onMutationCallback) { const changeRef = mutation.target.reduce((ref, key) => ref.child(key), ref); const changeSnap = new data_snapshot_1.DataSnapshot(changeRef, mutation.val, false, mutation.prev, snap.context()); // onMutationCallback(changeSnap, isRemote); // onMutationCallback uses try/catch for client callback clientEventEmitter.emit('mutation', { snapshot: changeSnap, isRemote }); // } return true; }); if (proceed) { clientEventEmitter.emit('cursor', context.acebase_cursor); // // NOTE: cursor is only present in mutations done remotely. For our own updates, server cursors are returned by ref.set and ref.update localMutationsEmitter.emit('mutations', { origin: 'remote', snap }); } else { console.warn(`Cached value of live data proxy on "${ref.path}" appears outdated, will be reloaded`); await reload(); } }); // Setup updating functionality: enqueue all updates, process them at next tick in the order they were issued let processPromise = Promise.resolve(); const mutationQueue = []; const transactions = []; const pushLocalMutations = async () => { // Sync all local mutations that are not in a transaction const mutations = []; for (let i = 0, m = mutationQueue[0]; i < mutationQueue.length; i++, m = mutationQueue[i]) { if (!transactions.find(t => RelativeNodeTarget.areEqual(t.target, m.target) || RelativeNodeTarget.isAncestor(t.target, m.target))) { mutationQueue.splice(i, 1); i--; mutations.push(m); } } if (mutations.length === 0) { return; } // Add current (new) values to mutations mutations.forEach(mutation => { mutation.value = (0, utils_1.cloneObject)(getTargetValue(cache, mutation.target)); }); // Run local onMutation & onChange callbacks in the next tick process_1.default.nextTick(() => { // Run onMutation callback for each changed node const context = { acebase_proxy: { id: proxyId, source: 'update' } }; // if (onMutationCallback) { mutations.forEach(mutation => { const mutationRef = mutation.target.reduce((ref, key) => ref.child(key), ref); const mutationSnap = new data_snapshot_1.DataSnapshot(mutationRef, mutation.value, false, mutation.previous, context); // onMutationCallback(mutationSnap, false); clientEventEmitter.emit('mutation', { snapshot: mutationSnap, isRemote: false }); }); // } // Notify local subscribers const snap = new data_snapshot_1.MutationsDataSnapshot(ref, mutations.map(m => ({ target: m.target, val: m.value, prev: m.previous })), context); localMutationsEmitter.emit('mutations', { origin: 'local', snap }); }); // Update database async // const batchId = ID.generate(); processPromise = mutations .reduce((mutations, m, i, arr) => { // Only keep top path mutations to prevent unneccessary child path updates if (!arr.some(other => RelativeNodeTarget.isAncestor(other.target, m.target))) { mutations.push(m); } return mutations; }, []) .reduce((updates, m) => { // Prepare db updates const target = m.target; if (target.length === 0) { // Overwrite this proxy's root value updates.push({ ref, target, value: cache, type: 'set', previous: m.previous }); } else { const parentTarget = target.slice(0, -1); const key = target.slice(-1)[0]; const parentRef = parentTarget.reduce((ref, key) => ref.child(key), ref); const parentUpdate = updates.find(update => update.ref.path === parentRef.path); const cacheValue = getTargetValue(cache, target); // m.value? const prevValue = m.previous; if (parentUpdate) { parentUpdate.value[key] = cacheValue; parentUpdate.previous[key] = prevValue; } else { updates.push({ ref: parentRef, target: parentTarget, value: { [key]: cacheValue }, type: 'update', previous: { [key]: prevValue } }); } } return updates; }, []) .reduce(async (promise, update /*, i, updates */) => { // Execute db update // i === 0 && console.log(`Proxy: processing ${updates.length} db updates to paths:`, updates.map(update => update.ref.path)); const context = { acebase_proxy: { id: proxyId, source: update.type, // update_id: ID.generate(), // batch_id: batchId, // batch_updates: updates.length }, }; await promise; await update.ref .context(context)[update.type](update.value) // .set or .update .catch(async (err) => { // console.warn(`Proxy could not update DB, should rollback (${update.type}) the proxy value of "${update.ref.path}" to: `, update.previous); if (options === null || options === void 0 ? void 0 : options.shouldRollback) { const rollback = await options.shouldRollback(err, { type: update.type, ref: update.ref, value: update.value, previous: update.previous }); if (rollback === false) { // Cancel rollback return; } } clientEventEmitter.emit('error', { source: 'update', message: `Error processing update of "/${ref.path}"`, details: err }); const context = { acebase_proxy: { id: proxyId, source: 'update-rollback' } }; const mutations = []; if (update.type === 'set') { setTargetValue(cache, update.target, update.previous); const mutationSnap = new data_snapshot_1.DataSnapshot(update.ref, update.previous, false, update.value, context); clientEventEmitter.emit('mutation', { snapshot: mutationSnap, isRemote: false }); mutations.push({ target: update.target, val: update.previous, prev: update.value }); } else { // update Object.keys(update.previous).forEach(key => { setTargetValue(cache, update.target.concat(key), update.previous[key]); const mutationSnap = new data_snapshot_1.DataSnapshot(update.ref.child(key), update.previous[key], false, update.value[key], context); clientEventEmitter.emit('mutation', { snapshot: mutationSnap, isRemote: false }); mutations.push({ target: update.target.concat(key), val: update.previous[key], prev: update.value[key] }); }); } // Run onMutation callback for each node being rolled back mutations.forEach(m => { const mutationRef = m.target.reduce((ref, key) => ref.child(key), ref); const mutationSnap = new data_snapshot_1.DataSnapshot(mutationRef, m.val, false, m.prev, context); clientEventEmitter.emit('mutation', { snapshot: mutationSnap, isRemote: false }); }); // Notify local subscribers: const snap = new data_snapshot_1.MutationsDataSnapshot(update.ref, mutations, context); localMutationsEmitter.emit('mutations', { origin: 'local', snap }); }); if (update.ref.cursor) { // Should also be available in context.acebase_cursor now clientEventEmitter.emit('cursor', update.ref.cursor); } }, processPromise); await processPromise; }; let syncInProgress = false; const syncPromises = []; const syncCompleted = () => { let resolve; const promise = new Promise(rs => resolve = rs); syncPromises.push({ resolve }); return promise; }; let processQueueTimeout = null; const scheduleSync = () => { if (!processQueueTimeout) { processQueueTimeout = setTimeout(async () => { syncInProgress = true; processQueueTimeout = null; await pushLocalMutations(); syncInProgress = false; syncPromises.splice(0).forEach(p => p.resolve()); }, 0); } }; const flagOverwritten = (target) => { if (!mutationQueue.find(m => RelativeNodeTarget.areEqual(m.target, target))) { mutationQueue.push({ target, previous: (0, utils_1.cloneObject)(getTargetValue(cache, target)) }); } // schedule database updates scheduleSync(); }; const localMutationsEmitter = new simple_event_emitter_1.SimpleEventEmitter(); const addOnChangeHandler = (target, callback) => { const isObject = (val) => val !== null && typeof val === 'object'; const mutationsHandler = async (details) => { var _a; const { snap, origin } = details; const context = snap.context(); const causedByOurProxy = ((_a = context.acebase_proxy) === null || _a === void 0 ? void 0 : _a.id) === proxyId; if (details.origin === 'remote' && causedByOurProxy) { // Any local changes already triggered subscription callbacks console.error('DEV ISSUE: mutationsHandler was called from remote event originating from our own proxy'); return; } const mutations = snap.val(false).filter(mutation => { // Keep mutations impacting the subscribed target: mutations on target, or descendant or ancestor of target return mutation.target.slice(0, target.length).every((key, i) => target[i] === key); }); if (mutations.length === 0) { return; } let newValue, previousValue; // If there is a mutation on the target itself, or parent/ancestor path, there can only be one. We can take a shortcut const singleMutation = mutations.find(m => m.target.length <= target.length); if (singleMutation) { const trailKeys = target.slice(singleMutation.target.length); newValue = trailKeys.reduce((val, key) => !isObject(val) || !(key in val) ? null : val[key], singleMutation.val); previousValue = trailKeys.reduce((val, key) => !isObject(val) || !(key in val) ? null : val[key], singleMutation.prev); } else { // All mutations are on children/descendants of our target // Construct new & previous values by combining cache and snapshot const currentValue = getTargetValue(cache, target); newValue = (0, utils_1.cloneObject)(currentValue); previousValue = (0, utils_1.cloneObject)(newValue); mutations.forEach(mutation => { // mutation.target is relative to proxy root const trailKeys = mutation.target.slice(target.length); for (let i = 0, val = newValue, prev = previousValue; i < trailKeys.length; i++) { // arr = PathInfo.getPathKeys(mutationPath).slice(PathInfo.getPathKeys(targetRef.path).length) const last = i + 1 === trailKeys.length, key = trailKeys[i]; if (last) { val[key] = mutation.val; if (val[key] === null) { delete val[key]; } prev[key] = mutation.prev; if (prev[key] === null) { delete prev[key]; } } else { val = val[key] = key in val ? val[key] : {}; prev = prev[key] = key in prev ? prev[key] : {}; } } }); } process_1.default.nextTick(() => { // Run callback with read-only (frozen) values in next tick let keepSubscription = true; try { keepSubscription = false !== callback(Object.freeze(newValue), Object.freeze(previousValue), !causedByOurProxy, context); } catch (err) { clientEventEmitter.emit('error', { source: origin === 'remote' ? 'remote_update' : 'local_update', message: 'Error running subscription callback', details: err }); } if (keepSubscription === false) { stop(); } }); }; localMutationsEmitter.on('mutations', mutationsHandler); const stop = () => { localMutationsEmitter.off('mutations', mutationsHandler); clientSubscriptions.splice(clientSubscriptions.findIndex(cs => cs.stop === stop), 1); }; clientSubscriptions.push({ target, stop }); return { stop }; }; const handleFlag = (flag, target, args) => { if (flag === 'write') { return flagOverwritten(target); } else if (flag === 'onChange') { return addOnChangeHandler(target, args.callback); } else if (flag === 'subscribe' || flag === 'observe') { const subscribe = (subscriber) => { const currentValue = getTargetValue(cache, target); subscriber.next(currentValue); const subscription = addOnChangeHandler(target, (value /*, previous, isRemote, context */) => { subscriber.next(value); }); return function unsubscribe() { subscription.stop(); }; }; if (flag === 'subscribe') { return subscribe; } // Try to load Observable const Observable = (0, optional_observable_1.getObservable)(); return new Observable(subscribe); } else if (flag === 'transaction') { const hasConflictingTransaction = transactions.some(t => RelativeNodeTarget.areEqual(target, t.target) || RelativeNodeTarget.isAncestor(target, t.target) || RelativeNodeTarget.isDescendant(target, t.target)); if (hasConflictingTransaction) { // TODO: Wait for this transaction to finish, then try again return Promise.reject(new Error('Cannot start transaction because it conflicts with another transaction')); } return new Promise(async (resolve) => { // If there are pending mutations on target (or deeper), wait until they have been synchronized const hasPendingMutations = mutationQueue.some(m => RelativeNodeTarget.areEqual(target, m.target) || RelativeNodeTarget.isAncestor(target, m.target)); if (hasPendingMutations) { if (!syncInProgress) { scheduleSync(); } await syncCompleted(); } const tx = { target, status: 'started', transaction: null }; transactions.push(tx); tx.transaction = { get status() { return tx.status; }, get completed() { return tx.status !== 'started'; }, get mutations() { return mutationQueue.filter(m => RelativeNodeTarget.areEqual(tx.target, m.target) || RelativeNodeTarget.isAncestor(tx.target, m.target)); }, get hasMutations() { return this.mutations.length > 0; }, async commit() { if (this.completed) { throw new Error(`Transaction has completed already (status '${tx.status}')`); } tx.status = 'finished'; transactions.splice(transactions.indexOf(tx), 1); if (syncInProgress) { // Currently syncing without our mutations await syncCompleted(); } scheduleSync(); await syncCompleted(); }, rollback() { // Remove mutations from queue if (this.completed) { throw new Error(`Transaction has completed already (status '${tx.status}')`); } tx.status = 'canceled'; const mutations = []; for (let i = 0; i < mutationQueue.length; i++) { const m = mutationQueue[i]; if (RelativeNodeTarget.areEqual(tx.target, m.target) || RelativeNodeTarget.isAncestor(tx.target, m.target)) { mutationQueue.splice(i, 1); i--; mutations.push(m); } } // Replay mutations in reverse order mutations.reverse() .forEach(m => { if (m.target.length === 0) { cache = m.previous; } else { setTargetValue(cache, m.target, m.previous); } }); // Remove transaction transactions.splice(transactions.indexOf(tx), 1); }, }; resolve(tx.transaction); }); } }; const snap = await ref.get({ cache_mode: 'allow', cache_cursor: options === null || options === void 0 ? void 0 : options.cursor }); // const gotOfflineStartValue = snap.context().acebase_origin === 'cache'; // if (gotOfflineStartValue) { // console.warn(`Started data proxy with cached value of "${ref.path}", check if its value is reloaded on next connection!`); // } if (snap.context().acebase_origin !== 'cache') { clientEventEmitter.emit('cursor', (_a = ref.cursor) !== null && _a !== void 0 ? _a : null); // latestCursor = snap.context().acebase_cursor ?? null; } loaded = true; cache = snap.val(); if (cache === null && typeof (options === null || options === void 0 ? void 0 : options.defaultValue) !== 'undefined') { cache = options.defaultValue; const context = { acebase_proxy: { id: proxyId, source: 'default', // update_id: ID.generate() }, }; await ref.context(context).set(cache); } proxy = createProxy({ root: { ref, get cache() { return cache; } }, target: [], id: proxyId, flag: handleFlag }); const assertProxyAvailable = () => { if (proxy === null) { throw new Error('Proxy was destroyed'); } }; const reload = async () => { // Manually reloads current value when cache is out of sync, which should only // be able to happen if an AceBaseClient is used without cache database, // and the connection to the server was lost for a while. In all other cases, // there should be no need to call this method. assertProxyAvailable(); mutationQueue.splice(0); // Remove pending mutations. Will be empty in production, but might not be while debugging, leading to weird behaviour. const snap = await ref.get({ allow_cache: false }); const oldVal = cache, newVal = snap.val(); cache = newVal; // Compare old and new values const mutations = (0, utils_1.getMutations)(oldVal, newVal); if (mutations.length === 0) { return; // Nothing changed } // Run onMutation callback for each changed node const context = snap.context(); // context might contain acebase_cursor if server support that context.acebase_proxy = { id: proxyId, source: 'reload' }; // if (onMutationCallback) { mutations.forEach(m => { const targetRef = getTargetRef(ref, m.target); const newSnap = new data_snapshot_1.DataSnapshot(targetRef, m.val, m.val === null, m.prev, context); clientEventEmitter.emit('mutation', { snapshot: newSnap, isRemote: true }); }); // } // Notify local subscribers const mutationsSnap = new data_snapshot_1.MutationsDataSnapshot(ref, mutations, context); localMutationsEmitter.emit('mutations', { origin: 'local', snap: mutationsSnap }); }; return { async destroy() { await processPromise; const promises = [ subscription.stop(), ...clientSubscriptions.map(cs => cs.stop()), ]; await Promise.all(promises); ['cursor', 'mutation', 'error'].forEach(event => clientEventEmitter.off(event)); cache = null; // Remove cache proxy = null; }, stop() { this.destroy(); }, get value() { assertProxyAvailable(); return proxy; }, get hasValue() { assertProxyAvailable(); return cache !== null; }, set value(val) { // Overwrite the value of the proxied path itself! assertProxyAvailable(); if (val !== null && typeof val === 'object' && val[isProxy]) { // Assigning one proxied value to another val = val.valueOf(); } flagOverwritten([]); cache = val; }, get ref() { return ref; }, get cursor() { return latestCursor; }, reload, onMutation(callback) { // Fires callback each time anything changes assertProxyAvailable(); clientEventEmitter.off('mutation'); // Mimic legacy behaviour that overwrites handler clientEventEmitter.on('mutation', ({ snapshot, isRemote }) => { try { callback(snapshot, isRemote); } catch (err) { clientEventEmitter.emit('error', { source: 'mutation_callback', message: 'Error in dataproxy onMutation callback', details: err }); } }); }, onError(callback) { // Fires callback each time anything goes wrong assertProxyAvailable(); clientEventEmitter.off('error'); // Mimic legacy behaviour that overwrites handler clientEventEmitter.on('error', (err) => { try { callback(err); } catch (err) { console.error(`Error in dataproxy onError callback: ${err.message}`); } }); }, on(event, callback) { clientEventEmitter.on(event, callback); }, off(event, callback) { clientEventEmitter.off(event, callback); }, }; } } exports.LiveDataProxy = LiveDataProxy; function getTargetValue(obj, target) { let val = obj; for (const key of target) { val = typeof val === 'object' && val !== null && key in val ? val[key] : null; } return val; } function setTargetValue(obj, target, value) { if (target.length === 0) { throw new Error('Cannot update root target, caller must do that itself!'); } const targetObject = target.slice(0, -1).reduce((obj, key) => obj[key], obj); const prop = target.slice(-1)[0]; if (value === null || typeof value === 'undefined') { // Remove it targetObject instanceof Array ? targetObject.splice(prop, 1) : delete targetObject[prop]; } else { // Set or update it targetObject[prop] = value; } } function getTargetRef(ref, target) { // Create new DataReference to prevent context reuse const path = path_info_1.PathInfo.get(ref.path).childPath(target); return new data_reference_1.DataReference(ref.db, path); } function createProxy(context) { const targetRef = getTargetRef(context.root.ref, context.target); const childProxies = []; const handler = { get(target, prop, receiver) { target = getTargetValue(context.root.cache, context.target); if (typeof prop === 'symbol') { if (prop.toString() === Symbol.iterator.toString()) { // Use .values for @@iterator symbol prop = 'values'; } else if (prop.toString() === isProxy.toString()) { return true; } else { return Reflect.get(target, prop, receiver); } } if (prop === 'valueOf') { return function valueOf() { return target; }; } if (target === null || typeof target !== 'object') { throw new Error(`Cannot read property "${prop}" of ${target}. Value of path "/${targetRef.path}" is not an object (anymore)`); } if (target instanceof Array && typeof prop === 'string' && /^[0-9]+$/.test(prop)) { // Proxy type definitions say prop can be a number, but this is never the case. prop = parseInt(prop); } const value = target[prop]; if (value === null) { // Removed property. Should never happen, but if it does: delete target[prop]; return; // undefined } // Check if we have a child proxy for this property already. // If so, and the properties' typeof value did not change, return that const childProxy = childProxies.find(proxy => proxy.prop === prop); if (childProxy) { if (childProxy.typeof === typeof value) { return childProxy.value; } childProxies.splice(childProxies.indexOf(childProxy), 1); } const proxifyChildValue = (prop) => { const value = target[prop]; // const childProxy = childProxies.find(child => child.prop === prop); if (childProxy) { if (childProxy.typeof === typeof value) { return childProxy.value; } childProxies.splice(childProxies.indexOf(childProxy), 1); } if (typeof value !== 'object') { // Can't proxify non-object values return value; } const newChildProxy = createProxy({ root: context.root, target: context.target.concat(prop), id: context.id, flag: context.flag }); childProxies.push({ typeof: typeof value, prop, value: newChildProxy }); return newChildProxy; }; const unproxyValue = (value) => { return value !== null && typeof value === 'object' && value[isProxy] ? value.getTarget() : value; }; // If the property contains a simple value, return it. if (['string', 'number', 'boolean'].includes(typeof value) || value instanceof Date || value instanceof path_reference_1.PathReference || value instanceof ArrayBuffer || (typeof value === 'object' && 'buffer' in value) // Typed Arrays ) { return value; } const isArray = target instanceof Array; if (prop === 'toString') { return function toString() { return `[LiveDataProxy for "${targetRef.path}"]`; }; } if (typeof value === 'undefined') { if (prop === 'push') { // Push item to an object collection return function push(item) { const childRef = targetRef.push(); context.flag('write', context.target.concat(childRef.key)); //, { previous: null } target[childRef.key] = item; return childRef.key; }; } if (prop === 'getTarget') { // Get unproxied readonly (but still live) version of data. return function (warn = true) { warn && console.warn('Use getTarget with caution - any changes will not be synchronized!'); return target; }; } if (prop === 'getRef') { // Gets the DataReference to this data target return function getRef() { const ref = getTargetRef(context.root.ref, context.target); return ref; }; } if (prop === 'forEach') { return function forEach(callback) { const keys = Object.keys(target); // Fix: callback with unproxied value let stop = false; for (let i = 0; !stop && i < keys.length; i++) { const key = keys[i]; const value = proxifyChildValue(key); //, target[key] stop = callback(value, key, i) === false; } }; } if (['values', 'entries', 'keys'].includes(prop)) { return function* generator() { const keys = Object.keys(target); for (const key of keys) { if (prop === 'keys') { yield key; } else { const value = proxifyChildValue(key); //, target[key] if (prop === 'entries') { yield [key, value]; } else { yield value; } } } }; } if (prop === 'toArray') { return function toArray(sortFn) { const arr = Object.keys(target).map(key => proxifyChildValue(key)); //, target[key] if (sortFn) { arr.sort(sortFn); } return arr; }; } if (prop === 'onChanged') { // Starts monitoring the value return function onChanged(callback) { return context.flag('onChange', context.target, { callback }); }; } if (prop === 'subscribe') { // Gets subscriber function to use with Observables, or custom handling return function subscribe() { return context.flag('subscribe', context.target); }; } if (prop === 'getObservable') { // Creates an observable for monitoring the value return function getObservable() { return context.flag('observe', context.target); }; } if (prop === 'getOrderedCollection') { return function getOrderedCollection(orderProperty, orderIncrement) { return new OrderedCollectionProxy(this, orderProperty, orderIncrement); }; } if (prop === 'startTransaction') { return function startTransaction() { return context.flag('transaction', context.target); }; } if (prop === 'remove' && !isArray) { // Removes target from object collection return function remove() { if (context.target.length === 0) { throw new Error('Can\'t remove proxy root value'); } const parent = getTargetValue(context.root.cache, context.target.slice(0, -1)); const key = context.target.slice(-1)[0]; context.flag('write', context.target); delete parent[key]; }; } return; // undefined } else if (typeof value === 'function') { if (isArray) { // Handle array methods const writeArray = (action) => { context.flag('write', context.target); return action(); }; const cleanArrayValues = (values) => values.map((value) => { value = unproxyValue(value); removeVoidProperties(value); return value; }); // Methods that directly change the array: if (prop === 'push') { return function push(...items) { items = cleanArrayValues(items); return writeArray(() => target.push(...items)); // push the items to the cache array }; } if (prop === 'pop') { return function pop() { return writeArray(() => target.pop()); }; } if (prop === 'splice') { return function splice(start, deleteCount, ...items) { items = cleanArrayValues(items); return writeArray(() => target.splice(start, deleteCount, ...items)); }; } if (prop === 'shift') { return function shift() { return writeArray(() => target.shift()); }; } if (prop === 'unshift') { return function unshift(...items) { items = cleanArrayValues(items); return writeArray(() => target.unshift(...items)); }; } if (prop === 'sort') { return function sort(compareFn) { return writeArray(() => target.sort(compareFn)); }; } if (prop === 'reverse') { return function reverse() { return writeArray(() => target.reverse()); }; } // Methods that do not change the array themselves, but // have callbacks that might, or return child values: if (['indexOf', 'lastIndexOf'].includes(prop)) { return function indexOf(item, start) { if (item !== null && typeof item === 'object' && item[isProxy]) { // Use unproxied value, or array.indexOf will return -1 (fixes issue #1) item = item.getTarget(false); } return target[prop](item, start); }; } if (['forEach', 'every', 'some', 'filter', 'map'].includes(prop)) { return function iterate(callback) { return target[prop]((value, i) => { return callback(proxifyChildValue(i), i, proxy); //, value }); }; } if (['reduce', 'reduceRight'].includes(prop)) { return function reduce(callback, initialValue) { return target[prop]((prev, value, i) => { return callback(prev, proxifyChildValue(i), i, proxy); //, value }, initialValue); }; } if (['find', 'findIndex'].includes(prop)) { return function find(callback) { let value = target[prop]((value, i) => { return callback(proxifyChildValue(i), i, proxy); // , value }); if (prop === 'find' && value) { const index = target.indexOf(value); value = proxifyChildValue(index); //, value } return value; }; } if (['values', 'entries', 'keys'].includes(prop)) { return function* generator() { for (let i = 0; i < target.length; i++) { if (prop === 'keys') { yield i; } else { const value = proxifyChildValue(i); //, target[i] if (prop === 'entries') { yield [i, value]; } else { yield value; } } } }; } } // Other function (or not an array), should not alter its value // return function fn(...args) { // return target[prop](...args); // } return value; } // Proxify any other value return proxifyChildValue(prop); //, value }, set(target, prop, value, receiver) { // Eg: chats.chat1.title = 'New chat title'; // target === chats.chat1, prop === 'title' target = getTargetValue(context.root.cache, context.target); if (typeof prop === 'symbol') { return Reflect.set(target, prop, value, receiver); } if (target === null || typeof target !== 'object') { throw new Error(`Cannot set property "${prop}" of ${target}. Value of path "/${targetRef.path}" is not an object`); } if (target instanceof Array && typeof prop === 'string') { if (!/^[0-9]+$/.test(prop)) { throw new Error(`Cannot set property "${prop}" on array value of path "/${targetRef.path}"`); } prop = parseInt(prop); } if (value !== null) { if (typeof value === 'object') { if (value[isProxy]) { // Assigning one proxied value to another value = value.valueOf(); } // else if (Object.isFrozen(value)) { // // Create a copy to unfreeze it // value = cloneObject(value); // } value = (0, utils_1.cloneObject)(value); // Fix #10, always clone objects so changes made through the proxy won't change the original object (and vice versa) } if ((0, utils_1.valuesAreEqual)(value, target[prop])) { //if (compareValues(value, target[prop]) === 'identical') { // (typeof value !== 'object' && target[prop] === value) { // not changing the actual value, ignore return true; } } if (context.target.some(key => typeof key === 'number')) { // Updating an object property inside an array. Flag the first array in target to be written. // Eg: when chat.members === [{ name: 'Ewout', id: 'someid' }] // --> chat.members[0].name = 'Ewout' --> Rewrite members array instead of chat/members[0]/name context.flag('write', context.target.slice(0, context.target.findIndex(key => typeof key === 'number'))); } else if (target instanceof Array) { // Flag the entire array to be overwritten context.flag('write', context.target); } else { // Flag child property context.flag('write', context.target.concat(prop)); } // Set cached value: if (value === null) { delete target[prop]; } else { removeVoidProperties(value); target[prop] = value; } return true; }, deleteProperty(target, prop) { target = getTargetValue(context.root.cache, context.target); if (target === null) { throw new Error(`Cannot delete property ${prop.toString()} of null`); } if (typeof prop === 'symbol') { return Reflect.deleteProperty(target, prop); } if (!(prop in target)) { return true; // Nothing to delete