@taraai/read-write
Version:
Synchronous NoSQL/Firestore for React
769 lines (662 loc) • 22.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = cacheReducer;
var _immer = _interopRequireDefault(require("immer"));
var _debug = _interopRequireDefault(require("debug"));
var _set = _interopRequireDefault(require("lodash/set"));
var _unset = _interopRequireDefault(require("lodash/unset"));
var _filter = _interopRequireDefault(require("lodash/filter"));
var _flow = _interopRequireDefault(require("lodash/flow"));
var _orderBy = _interopRequireDefault(require("lodash/orderBy"));
var _take = _interopRequireDefault(require("lodash/take"));
var _map = _interopRequireDefault(require("lodash/map"));
var _partialRight = _interopRequireDefault(require("lodash/partialRight"));
var _zip = _interopRequireDefault(require("lodash/zip"));
var _setWith = _interopRequireDefault(require("lodash/setWith"));
var _findIndex = _interopRequireDefault(require("lodash/findIndex"));
var _isMatch = _interopRequireDefault(require("lodash/isMatch"));
var _get = _interopRequireDefault(require("lodash/get"));
var _isEqual = _interopRequireDefault(require("lodash/isEqual"));
var _takeRight = _interopRequireDefault(require("lodash/takeRight"));
var _isEmpty = _interopRequireDefault(require("lodash/isEmpty"));
var _identity = _interopRequireDefault(require("lodash/identity"));
var _constants = require("../constants");
var _query = require("../utils/query");
var _mutate = require("../utils/mutate");
var _mutate2 = require("./utils/mutate");
var _profiling = _interopRequireDefault(require("../utils/profiling"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
const info = (0, _debug.default)('readwrite:cache');
const verbose = (0, _debug.default)('readwrite:verbose');
const isTimestamp = a => a instanceof Object && a.seconds !== undefined;
const formatTimestamp = function () {
let {
seconds
} = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
return seconds && new Intl.DateTimeFormat('en-US', {
dateStyle: 'short'
}).format(new Date(seconds * 1000));
};
const PROCESSES = {
'<': (a, b) => a < b,
'<=': (a, b) => a <= b,
'==': (a, b) => a === b,
'!=': (a, b) => a !== b,
'>=': (a, b) => a >= b,
'>': (a, b) => a > b,
'array-contains': (a, b) => a.includes(b),
in: (a, b) => Array.isArray(b) && b.includes(a),
'array-contains-any': (a, b) => b.some(b1 => a.includes(b1)),
'not-in': (a, b) => !b.includes(a),
'*': () => true
};
const PROCESSES_TIMESTAMP = {
'<': (a, b) => a.seconds < b.seconds || a.seconds === b.seconds && a.nanoseconds < b.nanoseconds,
'<=': (a, b) => a.seconds < b.seconds || a.seconds === b.seconds && a.nanoseconds <= b.nanoseconds,
'==': (a, b) => a.seconds === b.seconds && a.nanoseconds === b.nanoseconds,
'!=': (a, b) => a.seconds !== b.seconds || a.nanoseconds !== b.nanoseconds,
'>=': (a, b) => a.seconds > b.seconds || a.seconds === b.seconds && a.nanoseconds >= b.nanoseconds,
'>': (a, b) => a.seconds > b.seconds || a.seconds === b.seconds && a.nanoseconds > b.nanoseconds,
'array-contains': (a, b) => a.includes(b),
in: (a, b) => Array.isArray(b) && b.includes(a),
'array-contains-any': (a, b) => b.some(b1 => a.includes(b1)),
'not-in': (a, b) => !b.includes(a),
'*': () => true
};
const xfVerbose = title => (0, _partialRight.default)(_map.default, data => {
if (verbose.enabled) {
verbose(title, JSON.parse(JSON.stringify(data)));
}
return data;
});
const xfAllIds = _ref => {
let {
collection,
path: rawPath
} = _ref;
return function allIdsTransducer(state) {
const path = rawPath || collection;
const {
database: db = {},
databaseOverrides: dbo = {}
} = state;
const allIds = new Set([...Object.keys(db[path] || {}), ...Object.keys(dbo[path] || {})]);
return [Array.from(allIds).map(id => [path, id])];
};
};
const xfWhere = (_ref2, getDoc) => {
let {
where
} = _ref2;
if (!where) return [(0, _partialRight.default)(_map.default, _identity.default)];
const isFlat = typeof where[0] === 'string';
const clauses = isFlat ? [where] : where;
return clauses.map(_ref3 => {
let [field, op, val] = _ref3;
const fnc = isTimestamp(val) ? PROCESSES_TIMESTAMP[op] : PROCESSES[op] || (() => true);
return (0, _partialRight.default)(_map.default, tuples => (0, _filter.default)(tuples, function () {
let [path, id] = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
if (!path || !id) return false;
let value;
if (field === '__name__') {
value = id;
} else if (field.includes('.')) {
value = field.split('.').reduce((obj, subField) => obj && obj[subField], getDoc(path, id));
} else {
value = getDoc(path, id)[field];
}
if (value === undefined) value = null;
return fnc(value, val);
}));
});
};
const xfOrder = (_ref4, getDoc) => {
let {
orderBy: order
} = _ref4;
if (!order) return _identity.default;
const isFlat = typeof order[0] === 'string';
const orders = isFlat ? [order] : order;
const [fields, direction] = (0, _zip.default)(...orders.map(_ref5 => {
let [field, dir] = _ref5;
return [data => {
if (typeof data[field] === 'string') return data[field].toLowerCase();
if (isTimestamp(data[field])) return data[field].seconds;
return data[field];
}, dir || 'asc'];
}));
return (0, _partialRight.default)(_map.default, tuples => {
const docs = tuples.map(_ref6 => {
let [path, id] = _ref6;
return getDoc(path, id);
});
return (0, _orderBy.default)(docs, fields, direction).map(function () {
let {
id,
path
} = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
return path && id && [path, id];
});
});
};
const xfLimit = _ref7 => {
let {
limit,
endAt,
endBefore
} = _ref7;
if (!limit) return _identity.default;
const fromRight = (endAt || endBefore) !== undefined;
return fromRight ? function () {
let [arr] = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
return [(0, _takeRight.default)(arr, limit)];
} : function () {
let [arr] = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
return [(0, _take.default)(arr, limit)];
};
};
const xfPaginate = (query, getDoc) => {
const {
orderBy: order,
limit,
startAt,
startAfter,
endAt,
endBefore,
via
} = query;
const start = startAt || startAfter;
const end = endAt || endBefore;
const isAfter = startAfter !== undefined;
const isBefore = endBefore !== undefined;
const needsPagination = start || end || false;
if (!needsPagination || !order) return _identity.default;
let prop = null;
if (verbose.enabled) {
if (startAt) prop = 'startAt';else if (startAfter) prop = 'startAfter';else if (endAt) prop = 'endAt';else if (endBefore) prop = 'endBefore';
verbose(`paginate ${prop}:${formatTimestamp(needsPagination)} ` + `order:[${query && query.orderBy && query.orderBy[0]}, ${query && query.orderBy && query.orderBy[1]}] ` + `via:${via}`);
}
const isFlat = typeof order[0] === 'string';
const orders = isFlat ? [order] : order;
const isPaginateMatched = function (document, at) {
let before = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
let after = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false;
return orders.find((_ref8, idx) => {
let [field, sort = 'asc'] = _ref8;
const value = Array.isArray(at) ? at[idx] : at;
if (value === undefined) return false;
const isTime = isTimestamp(document[field]);
const proc = isTime ? PROCESSES_TIMESTAMP : PROCESSES;
let compare = process['=='];
if (startAt || endAt) compare = proc[sort === 'desc' ? '<=' : '>='];
if (startAfter || endBefore) compare = proc[sort === 'desc' ? '<' : '>'];
const isMatched = compare(document[field], value);
if (isMatched) {
if (verbose.enabled) {
const val = isTime ? formatTimestamp(document[field]) : document[field];
verbose(`${prop}: ${document.id}.${field} = ${val}`);
}
return true;
}
}) !== undefined;
};
return (0, _partialRight.default)(_map.default, tuples => {
const results = [];
let started = start === undefined;
tuples.forEach(_ref9 => {
let [path, id] = _ref9;
if (limit && results.length >= limit) return;
if (!started && start) {
if (isPaginateMatched(getDoc(path, id), start, undefined, isAfter)) {
started = true;
}
}
if (started && end) {
if (isPaginateMatched(getDoc(path, id), end, isBefore, undefined)) {
started = false;
}
}
if (started) {
results.push([path, id]);
}
});
return results;
});
};
function processOptimistic(query, state) {
const {
database,
databaseOverrides
} = state;
const {
via = 'memory',
collection
} = query;
const db = database && database[collection] || {};
const dbo = databaseOverrides && databaseOverrides[collection];
const getDoc = (path, id) => {
const data = db[id] || {};
const override = dbo && dbo[id];
return override ? { ...data,
...override
} : data;
};
if (verbose.enabled) {
verbose(JSON.parse(JSON.stringify(query)));
}
const process = (0, _flow.default)([xfAllIds(query), xfVerbose('xfAllIds'), ...xfWhere(query, getDoc), xfVerbose('xfWhere'), xfOrder(query, getDoc), xfVerbose('xfOrder'), xfPaginate(query, getDoc), xfVerbose('xfPaginate'), xfLimit(query), xfVerbose('xfLimit')]);
const ordered = process(state)[0];
return via === 'memory' && ordered.length === 0 ? undefined : ordered;
}
const skipReprocessing = (query, _ref10) => {
let {
databaseOverrides = {}
} = _ref10;
const {
collection,
via
} = query;
const fromFirestore = ['cache', 'server'].includes(via);
const hasNoOverrides = (0, _isEmpty.default)(databaseOverrides[collection]);
if (fromFirestore && hasNoOverrides) return true;
return false;
};
function reprocessQueries(draft, path) {
const done = (0, _profiling.default)(`reprocess.${path}`);
const queries = [];
const paths = Array.isArray(path) ? path : [path];
const overrides = draft.databaseOverrides && draft.databaseOverrides[path];
Object.keys(draft).forEach(key => {
if (['database', 'databaseOverrides'].includes(key)) return;
if (!paths.includes(draft[key].collection)) return;
if (skipReprocessing(draft[key], draft)) return;
queries.push(key);
const ordered = processOptimistic(draft[key], draft);
if (!draft[key].ordered || (ordered || []).toString() !== (draft[key].ordered || []).toString()) {
(0, _set.default)(draft, [key, 'ordered'], ordered);
(0, _set.default)(draft, [key, 'via'], !(0, _isEmpty.default)(overrides) ? 'optimistic' : 'memory');
}
});
if (info.enabled) {
const override = JSON.parse(JSON.stringify(draft.databaseOverrides || {}));
info(`reprocess ${path} (${queries.length} queries) with overrides`, override);
}
done();
}
function translateMutationToOverrides(_ref11) {
let {
payload
} = _ref11;
let db = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
let dbo = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
let {
reads,
writes
} = payload.data || {};
if (!writes) {
writes = Array.isArray(payload.data) ? payload.data : [payload.data];
} else if (!Array.isArray(writes)) {
writes = [writes];
}
const optimisticRead = (0, _mutate2.mutationReadFromCache)(reads, {
db,
dbo
});
const instructions = (0, _mutate2.mutationProduceWrites)(optimisticRead, writes);
const overrides = (0, _mutate2.mutationWriteOutput)(instructions, {
db,
dbo
});
if (_debug.default.enabled('readwrite:mutate')) {
(0, _debug.default)('readwrite:mutate')('Optimistic Cache', JSON.stringify({
'input-read': reads && Object.keys(reads).reduce((clone, key) => (({ ...clone,
[key]: (0, _mutate.getRead)(reads[key])
}), JSON.parse(JSON.stringify(reads)))),
'input-write-args': optimistic,
'output-writes': overrides
}, null, 2));
}
return overrides;
}
function cleanOverride(draft, _ref12) {
let {
path,
id,
data
} = _ref12;
if (!path || !id) return;
const override = (0, _get.default)(draft, ['databaseOverrides', path, id], false);
if (!override || data && !(0, _isMatch.default)(data, override)) return;
const keys = Object.keys(override);
const props = !data ? keys : keys.filter(key => {
const current = (0, _get.default)(data, key);
const optimistic = override[key];
if (current === null || current === undefined) {
return current === optimistic;
}
if (Array.isArray(current)) {
return current.every((val, idx) => val === optimistic[idx]);
}
if (typeof current === 'object') {
return Object.keys(current).every(key => current[key] === optimistic[key]);
}
return (0, _isEqual.default)(data[key], override[key]);
});
const isDone = props.length === Object.keys(override).length;
const isEmpty = isDone && Object.keys(draft.databaseOverrides[path] || {}).length === 1;
if (isEmpty) {
(0, _unset.default)(draft, ['databaseOverrides', path]);
} else if (isDone) {
(0, _unset.default)(draft, ['databaseOverrides', path, id]);
} else {
props.forEach(prop => {
(0, _unset.default)(draft, ['databaseOverrides', path, id, prop]);
});
}
}
const initialize = (state, _ref13) => {
let {
action,
key,
path
} = _ref13;
return (0, _immer.default)(state, draft => {
const done = (0, _profiling.default)(`cache.${action.type.replace(/(@@.+\/)/, '')}`, key);
if (!draft.database) {
(0, _set.default)(draft, ['database'], {});
(0, _set.default)(draft, ['databaseOverrides'], {});
}
const hasOptimistic = !(0, _isEmpty.default)(draft.databaseOverrides && draft.databaseOverrides[path]);
const via = {
undefined: hasOptimistic ? 'optimistic' : 'memory',
true: 'cache',
false: 'server'
}[action.payload.fromCache];
if (action.payload.data) {
Object.keys(action.payload.data).forEach(id => {
(0, _setWith.default)(draft, ['database', path, id], action.payload.data[id], Object);
cleanOverride(draft, {
path,
id,
data: action.payload.data[id]
});
});
}
const ordered = action.payload.ordered ? action.payload.ordered.map(_ref14 => {
let {
path: _path,
id
} = _ref14;
return [_path, id];
}) : processOptimistic(action.meta, draft);
(0, _set.default)(draft, [action.meta.storeAs || (0, _query.getQueryName)(action.meta)], {
ordered,
...action.meta,
via
});
reprocessQueries(draft, path);
done();
return draft;
});
};
const conclude = (state, _ref15) => {
let {
action,
key,
path
} = _ref15;
return (0, _immer.default)(state, draft => {
const done = (0, _profiling.default)(`cache.UNSET_LISTENER`, key);
if (draft[key]) {
if (!action.payload.preserveCache) {
(0, _unset.default)(draft, [key]);
}
reprocessQueries(draft, path);
}
done();
return draft;
});
};
const modify = (state, _ref16) => {
let {
action,
key,
path
} = _ref16;
return (0, _immer.default)(state, draft => {
const done = (0, _profiling.default)(`cache.DOCUMENT_MODIFIED`, key);
(0, _setWith.default)(draft, ['database', path, action.meta.doc], action.payload.data, Object);
cleanOverride(draft, {
path,
id: action.meta.doc,
data: action.payload.data
});
const {
payload
} = action;
const {
oldIndex = 0,
newIndex = 0
} = payload.ordered || {};
if (newIndex !== oldIndex) {
const tuple = payload.data && [payload.data.path, payload.data.id] || draft[key].ordered[oldIndex];
const {
ordered
} = draft[key] || {
ordered: []
};
const idx = (0, _findIndex.default)(ordered, [1, action.meta.doc]);
const isIndexChange = idx !== -1;
const isAddition = oldIndex === -1 || isIndexChange;
const isRemoval = newIndex === -1 || isIndexChange;
if (isRemoval && idx > -1) {
ordered.splice(idx, 0);
} else if (isAddition) {
ordered.splice(newIndex, 0, tuple);
}
(0, _set.default)(draft, [key, 'ordered'], ordered);
}
if (action.meta.reprocess !== false) {
reprocessQueries(draft, path);
}
done();
return draft;
});
};
const failure = (state, _ref17) => {
let {
action,
key,
path
} = _ref17;
return (0, _immer.default)(state, draft => {
const done = (0, _profiling.default)(`cache.MUTATE_FAILURE`, key);
if (action.payload.data || action.payload.args) {
const write = action.payload.data ? [{
writes: [action.payload.data]
}] : action.payload.args;
const allPaths = write.reduce((results, _ref18) => {
let {
writes
} = _ref18;
return [...results, ...writes.map(_ref19 => {
let {
collection,
path: _path,
doc,
id
} = _ref19;
info('remove override', `${collection}/${doc}`);
cleanOverride(draft, {
path: _path || collection,
id: id || doc
});
return path || collection;
})];
}, []);
const uniquePaths = Array.from(new Set(allPaths));
if (uniquePaths.length > 0) {
reprocessQueries(draft, uniquePaths);
}
}
done();
return draft;
});
};
const deletion = (state, _ref20) => {
let {
action,
key,
path
} = _ref20;
return (0, _immer.default)(state, draft => {
const done = (0, _profiling.default)(`cache.DELETE_SUCCESS`, key);
if (draft.database && draft.database[path]) {
(0, _unset.default)(draft, ['database', path, action.meta.doc]);
}
cleanOverride(draft, {
path,
id: action.meta.doc
});
if (draft[key] && draft[key].ordered) {
const idx = (0, _findIndex.default)(draft[key].ordered, [1, action.meta.doc]);
draft[key].ordered.splice(idx, 1);
}
reprocessQueries(draft, path);
done();
return draft;
});
};
const remove = (state, _ref21) => {
let {
action,
key,
path
} = _ref21;
return (0, _immer.default)(state, draft => {
const done = (0, _profiling.default)(`cache.DOCUMENT_REMOVED`, key);
cleanOverride(draft, {
path,
id: action.meta.doc,
data: action.payload.data
});
if (draft[key] && draft[key].ordered) {
const idx = (0, _findIndex.default)(draft[key].ordered, [1, action.meta.doc]);
const wasNotAlreadyRemoved = idx !== -1;
if (wasNotAlreadyRemoved) {
draft[key].ordered.splice(idx, 1);
}
}
reprocessQueries(draft, path);
done();
return draft;
});
};
const optimistic = (state, _ref22) => {
let {
action,
key,
path
} = _ref22;
return (0, _immer.default)(state, draft => {
(0, _setWith.default)(draft, ['databaseOverrides', path, action.meta.doc], action.payload.data, Object);
reprocessQueries(draft, path);
return draft;
});
};
const reset = (state, _ref23) => {
let {
action,
key,
path
} = _ref23;
return (0, _immer.default)(state, draft => {
cleanOverride(draft, {
path,
id: action.meta.doc
});
reprocessQueries(draft, path);
return draft;
});
};
const mutation = (state, _ref24) => {
let {
action,
key,
path
} = _ref24;
const {
_promise
} = action;
try {
const result = (0, _immer.default)(state, draft => {
const done = (0, _profiling.default)(`cache.MUTATE_START`, key);
const {
meta: {
timestamp
}
} = action;
if (action.payload && action.payload.data) {
const optimisiticUpdates = translateMutationToOverrides(action, draft.database) || [];
optimisiticUpdates.forEach(data => {
info('overriding', `${data.path}/${data.id}`, data);
(0, _setWith.default)(draft, ['databaseOverrides', data.path, data.id], data, Object);
});
const updatePaths = [...new Set(optimisiticUpdates.map(_ref25 => {
let {
path: _path
} = _ref25;
return _path;
}))];
updatePaths.forEach(_path => {
reprocessQueries(draft, _path);
});
}
done();
if (_promise && _promise.resolve) {
_promise.resolve();
}
return draft;
});
return result;
} catch (error) {
if (_promise && _promise.reject) {
_promise.reject(error);
}
return state;
}
};
const HANDLERS = {
[_constants.actionTypes.SET_LISTENER]: initialize,
[_constants.actionTypes.LISTENER_RESPONSE]: initialize,
[_constants.actionTypes.GET_SUCCESS]: initialize,
[_constants.actionTypes.UNSET_LISTENER]: conclude,
[_constants.actionTypes.DOCUMENT_ADDED]: modify,
[_constants.actionTypes.DOCUMENT_MODIFIED]: modify,
[_constants.actionTypes.DELETE_SUCCESS]: deletion,
[_constants.actionTypes.DOCUMENT_REMOVED]: remove,
[_constants.actionTypes.OPTIMISTIC_ADDED]: optimistic,
[_constants.actionTypes.OPTIMISTIC_MODIFIED]: optimistic,
[_constants.actionTypes.OPTIMISTIC_REMOVED]: reset,
[_constants.actionTypes.MUTATE_FAILURE]: failure,
[_constants.actionTypes.DELETE_FAILURE]: failure,
[_constants.actionTypes.UPDATE_FAILURE]: failure,
[_constants.actionTypes.SET_FAILURE]: failure,
[_constants.actionTypes.ADD_FAILURE]: failure,
[_constants.actionTypes.MUTATE_START]: mutation
};
function cacheReducer() {
let state = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
let action = arguments.length > 1 ? arguments[1] : undefined;
const fnc = HANDLERS[action.type];
if (!fnc) return state;
const key = !action.meta || !(action.meta.path || action.meta.collection) ? null : action.meta.storeAs || (0, _query.getQueryName)(action.meta);
const path = !action.meta ? null : action.meta.path || action.meta.collection;
return fnc(state, {
action,
key,
path
});
}