UNPKG

@nozbe/watermelondb

Version:

Build powerful React Native and React web apps that scale from hundreds to tens of thousands of records and remain fast

363 lines (359 loc) 16.6 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); exports.__esModule = true; exports.default = applyRemoteChanges; var _toConsumableArray2 = _interopRequireDefault(require("@babel/runtime/helpers/toConsumableArray")); var _extends2 = _interopRequireDefault(require("@babel/runtime/helpers/extends")); var _fp = require("../../utils/fp"); var _splitEvery = _interopRequireDefault(require("../../utils/fp/splitEvery")); var _allPromisesObj = _interopRequireDefault(require("../../utils/fp/allPromisesObj")); var _Result = require("../../utils/fp/Result"); var _common = require("../../utils/common"); var Q = _interopRequireWildcard(require("../../QueryDescription")); var _Schema = require("../../Schema"); var _helpers = require("./helpers"); function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(e) { return e ? t : r; })(e); } function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; } // NOTE: Creating JS models is expensive/memory-intensive, so we want to avoid it if possible // In replacement sync, we can avoid it if record already exists and didn't change. Note that we're not // using unsafeQueryRaw, because we DO want to reuse JS model if already in memory // This is only safe to do within a single db.write block, because otherwise we risk that the record // changed and we can no longer instantiate a JS model from an outdated raw record var unsafeFetchAsRaws = function (query) { return new Promise(function ($return, $error) { var db, result, raws; ({ db: db } = query.collection); return Promise.resolve((0, _Result.toPromise)(function (callback) { return db.adapter.underlyingAdapter.query(query.serialize(), callback); })).then(function ($await_2) { try { result = $await_2; raws = query.collection._cache.rawRecordsFromQueryResult(result); // FIXME: The above actually causes RecordCache corruption, because we're not adding record to // RecordCache, but adapter notes that we did. Temporary quick fix below to undo the optimization. raws.forEach(function (raw) { query.collection._cache._modelForRaw(raw, false); }); return $return(raws); } catch ($boundEx) { return $error($boundEx); } }, $error); }); }; var idsForChanges = function ({ created: created, updated: updated, deleted: deleted }) { var ids = []; created.forEach(function (record) { ids.push(record.id); }); updated.forEach(function (record) { ids.push(record.id); }); return ids.concat(deleted); }; var fetchRecordsForChanges = function (collection, changes) { var ids = idsForChanges(changes); if (ids.length) { return unsafeFetchAsRaws(collection.query(Q.where((0, _Schema.columnName)('id'), Q.oneOf(ids)))); } return Promise.resolve([]); }; function recordsToApplyRemoteChangesTo_incremental(collection, changes, context) { return new Promise(function ($return, $error) { var db, table, deletedIds, deletedIdsSet, rawRecords, locallyDeletedIds; ({ db: db } = context); ({ table: table } = collection); ({ deleted: deletedIds } = changes); deletedIdsSet = new Set(deletedIds); return Promise.resolve(Promise.all([fetchRecordsForChanges(collection, changes), db.adapter.getDeletedRecords(table)])).then(function ($await_3) { try { [rawRecords, locallyDeletedIds] = $await_3; return $return((0, _extends2.default)({}, changes, { recordsMap: new Map(rawRecords.map(function (raw) { return [raw.id, raw]; })), locallyDeletedIds: locallyDeletedIds, recordsToDestroy: rawRecords.filter(function (raw) { return deletedIdsSet.has(raw.id); }).map(function (raw) { return (0, _helpers.recordFromRaw)(raw, collection); }), deletedRecordsToDestroy: locallyDeletedIds.filter(function (id) { return deletedIdsSet.has(id); }) })); } catch ($boundEx) { return $error($boundEx); } }, $error); }); } function recordsToApplyRemoteChangesTo_replacement(collection, changes, context) { return new Promise(function ($return, $error) { var _context$strategy$exp, _context$strategy$exp2, db, table, queryForReplacement, created, updated, changesDeletedIds, deletedIdsSet, rawRecords, locallyDeletedIds, replacementRecords, recordsToKeep; ({ db: db } = context); ({ table: table } = collection); queryForReplacement = context.strategy && 'object' === typeof context.strategy && context.strategy.experimentalQueryRecordsForReplacement ? null === (_context$strategy$exp = (_context$strategy$exp2 = context.strategy.experimentalQueryRecordsForReplacement)[table]) || void 0 === _context$strategy$exp ? void 0 : _context$strategy$exp.call(_context$strategy$exp2) : null; ({ created: created, updated: updated, deleted: changesDeletedIds } = changes); deletedIdsSet = new Set(changesDeletedIds); return Promise.resolve(Promise.all([unsafeFetchAsRaws(collection.query(queryForReplacement ? [Q.or(Q.where((0, _Schema.columnName)('id'), Q.oneOf(idsForChanges(changes))), Q.and(queryForReplacement))] : [])), db.adapter.getDeletedRecords(table)])).then(function ($await_4) { try { [rawRecords, locallyDeletedIds] = $await_4; return Promise.resolve(function () { return new Promise(function ($return, $error) { var clauses, modifiedQuery; if (queryForReplacement) { clauses = queryForReplacement; modifiedQuery = collection.query(clauses); modifiedQuery.description = modifiedQuery._rawDescription; return Promise.resolve(modifiedQuery.fetchIds()).then(function ($await_5) { try { return $return(new Set($await_5)); } catch ($boundEx) { return $error($boundEx); } }, $error); } return $return(null); }); }()).then(function ($await_6) { try { replacementRecords = $await_6; recordsToKeep = new Set([].concat((0, _toConsumableArray2.default)(created.map(function (record) { return record.id; })), (0, _toConsumableArray2.default)(updated.map(function (record) { return record.id; })))); return $return((0, _extends2.default)({}, changes, { recordsMap: new Map(rawRecords.map(function (raw) { return [raw.id, raw]; })), locallyDeletedIds: locallyDeletedIds, recordsToDestroy: rawRecords.filter(function (raw) { if (deletedIdsSet.has(raw.id)) { return true; } var subjectToReplacement = replacementRecords ? replacementRecords.has(raw.id) : true; return subjectToReplacement && !recordsToKeep.has(raw.id) && 'created' !== raw._status; }).map(function (raw) { return (0, _helpers.recordFromRaw)(raw, collection); }), deletedRecordsToDestroy: locallyDeletedIds.filter(function (id) { if (deletedIdsSet.has(id)) { return true; } var subjectToReplacement = replacementRecords ? replacementRecords.has(id) : true; return subjectToReplacement && !recordsToKeep.has(id); }) })); } catch ($boundEx) { return $error($boundEx); } }, $error); } catch ($boundEx) { return $error($boundEx); } }, $error); }); } var strategyForCollection = function (collection, strategy) { if (!strategy) { return 'incremental'; } else if ('string' === typeof strategy) { return strategy; } return strategy.override[collection.table] || strategy.default; }; function recordsToApplyRemoteChangesTo(collection, changes, context) { return new Promise(function ($return) { var strategy = strategyForCollection(collection, context.strategy); (0, _common.invariant)(['incremental', 'replacement'].includes(strategy), '[Sync] Invalid pull strategy'); switch (strategy) { case 'replacement': return $return(recordsToApplyRemoteChangesTo_replacement(collection, changes, context)); case 'incremental': default: return $return(recordsToApplyRemoteChangesTo_incremental(collection, changes, context)); } return $return(); }); } var getAllRecordsToApply = function (remoteChanges, context) { var { db: db } = context; return (0, _allPromisesObj.default)((0, _fp.pipe)((0, _fp.filterObj)(function (_changes, tableName) { var collection = db.get(tableName); if (!collection) { _common.logger.warn("You are trying to sync a collection named ".concat(tableName, ", but it does not exist. Will skip it (for forward-compatibility). If this is unexpected, perhaps you forgot to add it to your Database constructor's modelClasses property?")); } return !!collection; }), (0, _fp.mapObj)(function (changes, tableName) { return recordsToApplyRemoteChangesTo(db.get(tableName), changes, context); }))(remoteChanges)); }; function validateRemoteRaw(raw) { // TODO: I think other code is actually resilient enough to handle illegal _status and _changed // would be best to change that part to a warning - but tests are needed (0, _common.invariant)(raw && 'object' === typeof raw && 'id' in raw && !('_status' in raw || '_changed' in raw), "[Sync] Invalid raw record supplied to Sync. Records must be objects, must have an 'id' field, and must NOT have a '_status' or '_changed' fields"); } function prepareApplyRemoteChangesToCollection(recordsToApply, collection, context) { var { db: db, sendCreatedAsUpdated: sendCreatedAsUpdated, log: log, conflictResolver: conflictResolver } = context; var { table: table } = collection; var { created: created, updated: updated, recordsToDestroy: deleted, recordsMap: recordsMap, locallyDeletedIds: locallyDeletedIds } = recordsToApply; // if `sendCreatedAsUpdated`, server should send all non-deleted records as `updated` // log error if it doesn't — but disable standard created vs updated errors if (sendCreatedAsUpdated && created.length) { (0, _common.logError)("[Sync] 'sendCreatedAsUpdated' option is enabled, and yet server sends some records as 'created'"); } var recordsToBatch = []; // mutating - perf critical // Insert and update records created.forEach(function (raw) { validateRemoteRaw(raw); var currentRecord = recordsMap.get(raw.id); if (currentRecord) { (0, _common.logError)("[Sync] Server wants client to create record ".concat(table, "#").concat(raw.id, ", but it already exists locally. This may suggest last sync partially executed, and then failed; or it could be a serious bug. Will update existing record instead.")); recordsToBatch.push((0, _helpers.prepareUpdateFromRaw)(currentRecord, raw, collection, log, conflictResolver)); } else if (locallyDeletedIds.includes(raw.id)) { (0, _common.logError)("[Sync] Server wants client to create record ".concat(table, "#").concat(raw.id, ", but it already exists locally and is marked as deleted. This may suggest last sync partially executed, and then failed; or it could be a serious bug. Will delete local record and recreate it instead.")); // Note: we're not awaiting the async operation (but it will always complete before the batch) db.adapter.destroyDeletedRecords(table, [raw.id]); recordsToBatch.push((0, _helpers.prepareCreateFromRaw)(collection, raw)); } else { recordsToBatch.push((0, _helpers.prepareCreateFromRaw)(collection, raw)); } }); updated.forEach(function (raw) { validateRemoteRaw(raw); var currentRecord = recordsMap.get(raw.id); if (currentRecord) { recordsToBatch.push((0, _helpers.prepareUpdateFromRaw)(currentRecord, raw, collection, log, conflictResolver)); } else if (!locallyDeletedIds.includes(raw.id)) { // Record doesn't exist (but should) — just create it sendCreatedAsUpdated || (0, _common.logError)("[Sync] Server wants client to update record ".concat(table, "#").concat(raw.id, ", but it doesn't exist locally. This could be a serious bug. Will create record instead. If this was intentional, please check the flag sendCreatedAsUpdated in https://watermelondb.dev/docs/Sync/Frontend#additional-synchronize-flags")); recordsToBatch.push((0, _helpers.prepareCreateFromRaw)(collection, raw)); } // Nothing to do, record was locally deleted, deletion will be pushed later }); deleted.forEach(function (record) { // $FlowFixMe recordsToBatch.push(record.prepareDestroyPermanently()); }); return recordsToBatch; } var destroyAllDeletedRecords = function (db, recordsToApply) { return new Promise(function ($return, $error) { var promises = (0, _fp.toPairs)(recordsToApply).map(function ([tableName, { deletedRecordsToDestroy: deletedRecordsToDestroy }]) { return deletedRecordsToDestroy.length ? db.adapter.destroyDeletedRecords(tableName, deletedRecordsToDestroy) : null; }); return Promise.resolve(Promise.all(promises)).then(function () { try { return $return(); } catch ($boundEx) { return $error($boundEx); } }, $error); }); }; var applyAllRemoteChanges = function (recordsToApply, context) { return new Promise(function ($return, $error) { var db, allRecords; ({ db: db } = context); allRecords = []; (0, _fp.toPairs)(recordsToApply).forEach(function ([tableName, records]) { prepareApplyRemoteChangesToCollection(records, db.get(tableName), context).forEach(function (record) { allRecords.push(record); }); }); // $FlowFixMe return Promise.resolve(db.batch(allRecords)).then(function () { try { return $return(); } catch ($boundEx) { return $error($boundEx); } }, $error); }); }; // See _unsafeBatchPerCollection - temporary fix var unsafeApplyAllRemoteChangesByBatches = function (recordsToApply, context) { return new Promise(function ($return, $error) { var db, promises; ({ db: db } = context); promises = []; (0, _fp.toPairs)(recordsToApply).forEach(function ([tableName, records]) { var preparedModels = prepareApplyRemoteChangesToCollection(records, db.get(tableName), context); (0, _splitEvery.default)(5000, preparedModels).forEach(function (recordBatch) { promises.push(db.batch(recordBatch)); }); }); return Promise.resolve(Promise.all(promises)).then(function () { try { return $return(); } catch ($boundEx) { return $error($boundEx); } }, $error); }); }; function applyRemoteChanges(remoteChanges, context) { return new Promise(function ($return, $error) { var db, _unsafeBatchPerCollection, recordsToApply; ({ db: db, _unsafeBatchPerCollection: _unsafeBatchPerCollection } = context); return Promise.resolve(getAllRecordsToApply(remoteChanges, context)).then(function ($await_10) { try { recordsToApply = $await_10; return Promise.resolve(Promise.all([destroyAllDeletedRecords(db, recordsToApply), _unsafeBatchPerCollection ? unsafeApplyAllRemoteChangesByBatches(recordsToApply, context) : applyAllRemoteChanges(recordsToApply, context)])).then(function () { try { return $return(); } catch ($boundEx) { return $error($boundEx); } }, $error); } catch ($boundEx) { return $error($boundEx); } }, $error); }); }