acebase-core
Version:
Shared AceBase core components, no need to install manually
986 lines (985 loc) • 60.2 kB
JavaScript
"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