@nozbe/watermelondb
Version:
Build powerful React Native and React web apps that scale from hundreds to tens of thousands of records and remain fast
438 lines (420 loc) • 17.2 kB
JavaScript
"use strict";
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
exports.__esModule = true;
exports.default = void 0;
exports.setExperimentalAllowsFatalError = setExperimentalAllowsFatalError;
var _toConsumableArray2 = _interopRequireDefault(require("@babel/runtime/helpers/toConsumableArray"));
var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass"));
var _rx = require("../utils/rx");
var _common = require("../utils/common");
var _fp = require("../utils/fp");
var _compat = _interopRequireDefault(require("../adapters/compat"));
var _CollectionMap = _interopRequireDefault(require("./CollectionMap"));
var _WorkQueue = _interopRequireDefault(require("./WorkQueue"));
var experimentalAllowsFatalError = false;
function setExperimentalAllowsFatalError() {
experimentalAllowsFatalError = true;
}
var Database = exports.default = /*#__PURE__*/function () {
/**
* Database's adapter - the low-level connection with the underlying database (e.g. SQLite)
*
* Unless you understand WatermelonDB's internals, you SHOULD NOT use adapter directly.
* Running queries, or updating/deleting records on the adapter will corrupt the in-memory cache
* if special care is not taken
*/
// (experimental) if true, Database is in a broken state and should not be used anymore
function Database(options) {
this._workQueue = new _WorkQueue.default(this);
this._isBroken = false;
this._pendingNotificationBatches = 0;
this._pendingNotificationChanges = [];
this._subscribers = [];
this._resetCount = 0;
this._isBeingReset = false;
this.experimentalIsVerbose = false;
var {
adapter: adapter,
modelClasses: modelClasses
} = options;
if ('production' !== process.env.NODE_ENV) {
(0, _common.invariant)(adapter, "Missing adapter parameter for new Database()");
(0, _common.invariant)(modelClasses && Array.isArray(modelClasses), "Missing modelClasses parameter for new Database()");
}
this.adapter = new _compat.default(adapter);
this.schema = adapter.schema;
this.collections = new _CollectionMap.default(this, modelClasses);
}
/**
* Returns a `Collection` for a given table name
*/
var _proto = Database.prototype;
_proto.get = function (tableName) {
return this.collections.get(tableName);
}
/**
* Returns a `LocalStorage` (WatermelonDB-based localStorage/AsyncStorage alternative)
*/;
/*:: batch: ArrayOrSpreadFn<?Model | false, Promise<void>> */
/**
* Executes multiple prepared operations
*
* Pass a list (or array) of operations like so:
* - `collection.prepareCreate(...)`
* - `record.prepareUpdate(...)`
* - `record.prepareMarkAsDeleted()` (or `record.prepareDestroyPermanently()`)
*
* Note that falsy values (null, undefined, false) passed to batch are simply ignored
* so you can use patterns like `.batch(condition && record.prepareUpdate(...))` for convenience.
*
* Note: This method must be called within a Writer {@link Database#write}.
*/
// $FlowFixMe
_proto.batch = function (...records) {
return new Promise(function ($return, $error) {
var _this, actualRecords, batchOperations, changeNotifications, debugInfo, changes;
_this = this;
actualRecords = (0, _fp.fromArrayOrSpread)(records, 'Database.batch', 'Model');
this._ensureInWriter("Database.batch()");
// performance critical - using mutations
batchOperations = [];
changeNotifications = {};
actualRecords.forEach(function (record) {
if (!record) {
return;
}
var preparedState = record._preparedState;
if (!preparedState) {
(0, _common.invariant)('disposable' !== record._raw._status, "Cannot batch a disposable record");
throw new Error("Cannot batch a record that doesn't have a prepared create/update/delete");
}
var raw = record._raw;
var {
id: id
} = raw; // faster than Model.id
var {
table: table
} = record.constructor; // faster than Model.table
var changeType;
if ('update' === preparedState) {
batchOperations.push(['update', table, raw]);
changeType = 'updated';
} else if ('create' === preparedState) {
batchOperations.push(['create', table, raw]);
changeType = 'created';
} else if ('markAsDeleted' === preparedState) {
batchOperations.push(['markAsDeleted', table, id]);
changeType = 'destroyed';
} else if ('destroyPermanently' === preparedState) {
batchOperations.push(['destroyPermanently', table, id]);
changeType = 'destroyed';
} else {
(0, _common.invariant)(false, 'bad preparedState');
}
if ('create' !== preparedState) {
// We're (unsafely) assuming that batch will succeed and removing the "pending" state so that
// subsequent changes to the record don't trip up the invariant
// TODO: What if this fails?
record._preparedState = null;
}
if (!changeNotifications[table]) {
changeNotifications[table] = [];
}
changeNotifications[table].push({
record: record,
type: changeType
});
});
return Promise.resolve(this.adapter.batch(batchOperations)).then(function () {
try {
// Debug info
if (this.experimentalIsVerbose) {
debugInfo = batchOperations.map(function ([type, table, rawOrId]) {
switch (type) {
case 'create':
case 'update':
return "".concat(type, " ").concat(table, "#").concat(rawOrId.id);
case 'markAsDeleted':
case 'destroyPermanently':
return "".concat(type, " ").concat(table, "#").concat(rawOrId);
default:
return "".concat(type, "???");
}
}).join(', ');
_common.logger.debug("batch: ".concat(debugInfo));
}
// NOTE: We must make two passes to ensure all changes to caches are applied before subscribers are called
changes = Object.entries(changeNotifications);
changes.forEach(function ([table, changeSet]) {
_this.collections.get(table)._applyChangesToCache(changeSet);
});
this._notify(changes);
return $return(undefined); // shuts up flow
} catch ($boundEx) {
return $error($boundEx);
}
}.bind(this), $error);
}.bind(this));
};
_proto._notify = function (changes) {
var _this2 = this;
if (0 < this._pendingNotificationBatches) {
this._pendingNotificationChanges.push(changes);
return;
}
var affectedTables = new Set(changes.map(function ([table]) {
return table;
}));
this._subscribers.forEach(function ([tables, subscriber]) {
if (tables.some(function (table) {
return affectedTables.has(table);
})) {
subscriber();
}
});
changes.forEach(function ([table, changeSet]) {
_this2.collections.get(table)._notify(changeSet);
});
};
_proto.experimentalBatchNotifications = function (work) {
return new Promise(function ($return, $error) {
var $Try_1_Finally = function ($Try_1_Exit) {
return function ($Try_1_Value) {
try {
this._pendingNotificationBatches -= 1;
if (0 === this._pendingNotificationBatches) {
changes = this._pendingNotificationChanges;
this._pendingNotificationChanges = [];
changes.forEach(function (_changes) {
return _this3._notify(_changes);
});
}
return $Try_1_Exit && $Try_1_Exit.call(this, $Try_1_Value);
} catch ($boundEx) {
return $error($boundEx);
}
}.bind(this);
}.bind(this);
var _this3, result, changes;
_this3 = this;
var $Try_1_Catch = function $Try_1_Catch($exception_2) {
try {
throw $exception_2;
} catch ($boundEx) {
return $Try_1_Finally($error)($boundEx);
}
};
// TODO: Document & add tests if this proves useful
try {
this._pendingNotificationBatches += 1;
return Promise.resolve(work()).then(function ($await_6) {
try {
result = $await_6;
return $Try_1_Finally($return)(result);
} catch ($boundEx) {
return $Try_1_Catch($boundEx);
}
}, $Try_1_Catch);
} catch ($exception_2) {
$Try_1_Catch($exception_2)
}
}.bind(this));
}
/**
* Schedules a Writer
*
* Writer is a block of code, inside of which you can modify the database
* (call `Collection.create`, `Model.update`, `Database.batch` and so on).
*
* In a Writer, you're guaranteed that no other Writer is simultaneously executing. Therefore, you
* can rely on the results of queries and other asynchronous operations - they won't change for
* the duration of this Writer (except if changed by it).
*
* To call another Writer (or Reader) from this one without deadlocking, use `callWriter`
* (or `callReader`).
*
* See docs for more details and a practical guide.
*
* @param work - Block of code to execute
* @param [description] - Debug description of this Writer
*/;
_proto.write = function (work, description) {
return this._workQueue.enqueue(work, description, true);
}
/**
* Schedules a Reader
*
* In a Reader, you're guaranteed that no Writer is running at the same time. Therefore, you can
* run many queries or other asynchronous operations, and you can rely on their results - they
* won't change for the duration of this Reader. However, other Readers might run concurrently.
*
* To call another Reader from this one, use `callReader`
*
* See docs for more details and a practical guide.
*
* @param work - Block of code to execute
* @param [description] - Debug description of this Reader
*/;
_proto.read = function (work, description) {
return this._workQueue.enqueue(work, description, false);
}
/**
* Returns an `Observable` that emits a signal (`null`) immediately, and on every change in
* any of the passed tables.
*
* A set of changes made is passed with the signal, with an array of changes per-table
* (Currently, if changes are made to multiple different tables, multiple signals will be emitted,
* even if they're made with a batch. However, this behavior might change. Use Rx to debounce,
* throttle, merge as appropriate for your use case.)
*
* Warning: You can easily introduce performance bugs in your application by using this method
* inappropriately.
*/;
_proto.withChangesForTables = function (tables) {
var _this4 = this;
var changesSignals = tables.map(function (table) {
return _this4.collections.get(table).changes;
});
return _rx.merge.apply(void 0, (0, _toConsumableArray2.default)(changesSignals)).pipe((0, _rx.startWith)(null));
};
/**
* Notifies `subscriber` on change in any of the passed tables.
*
* A single notification will be sent per `database.batch()` call.
* (Currently, no details about the changes made are provided, only a signal, but this behavior
* might change. Currently, subscribers are called before `withChangesForTables`).
*
* Warning: You can easily introduce performance bugs in your application by using this method
* inappropriately.
*/
_proto.experimentalSubscribe = function (tables, subscriber, debugInfo) {
var _this5 = this;
if (!tables.length) {
return _fp.noop;
}
var entry = [tables, subscriber, debugInfo];
this._subscribers.push(entry);
return function () {
var idx = _this5._subscribers.indexOf(entry);
-1 !== idx && _this5._subscribers.splice(idx, 1);
};
};
/**
* Resets the database
*
* This permanently deletes the database (all records, metadata, and `LocalStorage`) and sets
* up an empty database.
*
* Special care must be taken to safely reset the database. Ideally, you should reset your app
* to an empty / "logging out" state while doing this. Specifically:
*
* - You MUST NOT hold onto Watermelon records other than this `Database`. Do not keep references
* to records, collections, or any other objects from before database reset
* - You MUST NOT observe any Watermelon state. All Database, Collection, Query, and Model
* observers/subscribers should be disposed of before resetting
* - You SHOULD NOT have any pending (queued) Readers or Writers. Pending work will be aborted
* (rejected with an error)
*/
_proto.unsafeResetDatabase = function () {
return new Promise(function ($return, $error) {
var $Try_3_Finally = function ($Try_3_Exit) {
return function ($Try_3_Value) {
try {
this._isBeingReset = false;
return $Try_3_Exit && $Try_3_Exit.call(this, $Try_3_Value);
} catch ($boundEx) {
return $error($boundEx);
}
}.bind(this);
}.bind(this);
var adapter, ErrorAdapter;
this._ensureInWriter("Database.unsafeResetDatabase()");
var $Try_3_Post = function $Try_3_Post() {
try {
return $return();
} catch ($boundEx) {
return $error($boundEx);
}
};
var $Try_3_Catch = function $Try_3_Catch($exception_4) {
try {
throw $exception_4;
} catch ($boundEx) {
return $Try_3_Finally($error)($boundEx);
}
};
try {
this._isBeingReset = true;
// First kill actions, to ensure no more traffic to adapter happens
this._workQueue._abortPendingWork();
// Kill ability to call adapter methods during reset (to catch bugs if someone does this)
({
adapter: adapter
} = this);
ErrorAdapter = require('../adapters/error').default;
this.adapter = new ErrorAdapter();
// Check for illegal subscribers
if (this._subscribers.length) {
// TODO: This should be an error, not a console.log, but actually useful diagnostics are necessary for this to work, otherwise people will be confused
// eslint-disable-next-line no-console
console.log("Application error! Unexpected ".concat(this._subscribers.length, " Database subscribers were detected during database.unsafeResetDatabase() call. App should not hold onto subscriptions or Watermelon objects while resetting database."));
// eslint-disable-next-line no-console
console.log(this._subscribers);
this._subscribers = [];
}
// Clear the database
return Promise.resolve(adapter.unsafeResetDatabase()).then(function () {
try {
// Only now clear caches, since there may have been queued fetches from DB still bringing in items to cache
Object.values(this.collections.map).forEach(function (collection) {
// $FlowFixMe
collection._cache.unsafeClear();
});
// Restore working Database
this._resetCount += 1;
this.adapter = adapter;
return $Try_3_Finally($Try_3_Post)();
} catch ($boundEx) {
return $Try_3_Catch($boundEx);
}
}.bind(this), $Try_3_Catch);
} catch ($exception_4) {
$Try_3_Catch($exception_4)
}
}.bind(this));
}
// (experimental) if true, Models will print to console diagnostic information on every
// prepareCreate/Update/Delete call, as well as on commit (Database.batch() call). Note that this
// has a significant performance impact so should only be enabled when debugging.
;
_proto._ensureInWriter = function (debugName) {
(0, _common.invariant)(this._workQueue.isWriterRunning, "".concat(debugName, " can only be called from inside of a Writer. See docs for more details."));
}
// (experimental) puts Database in a broken state
// TODO: Not used anywhere yet
;
_proto._fatalError = function (error) {
if (!experimentalAllowsFatalError) {
_common.logger.warn('Database is now broken, but experimentalAllowsFatalError has not been enabled to do anything about it...');
return;
}
this._isBroken = true;
_common.logger.error('Database is broken. App must be reloaded before continuing.');
// TODO: Passing this to an adapter feels wrong, but it's tricky.
// $FlowFixMe
if (this.adapter.underlyingAdapter._fatalError) {
// $FlowFixMe
this.adapter.underlyingAdapter._fatalError(error);
}
};
return (0, _createClass2.default)(Database, [{
key: "localStorage",
get: function get() {
if (!this._localStorage) {
var LocalStorageClass = require('./LocalStorage').default;
this._localStorage = new LocalStorageClass(this);
}
return this._localStorage;
}
}]);
}();