UNPKG

@taraai/read-write

Version:

Synchronous NoSQL/Firestore for React

769 lines (662 loc) 22.6 kB
"use strict"; 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 }); }