UNPKG

12.4 kBJavaScriptView Raw
1/*
2Copyright 2017 Vector Creations Ltd
3Copyright 2018 New Vector Ltd
4Copyright 2019 The Matrix.org Foundation C.I.C.
5
6Licensed under the Apache License, Version 2.0 (the "License");
7you may not use this file except in compliance with the License.
8You may obtain a copy of the License at
9
10 http://www.apache.org/licenses/LICENSE-2.0
11
12Unless required by applicable law or agreed to in writing, software
13distributed under the License is distributed on an "AS IS" BASIS,
14WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15See the License for the specific language governing permissions and
16limitations under the License.
17*/
18
19/* eslint-disable babel/no-invalid-this */
20
21import {MemoryStore} from "./memory";
22import * as utils from "../utils";
23import {EventEmitter} from 'events';
24import {LocalIndexedDBStoreBackend} from "./indexeddb-local-backend.js";
25import {RemoteIndexedDBStoreBackend} from "./indexeddb-remote-backend.js";
26import {User} from "../models/user";
27import {MatrixEvent} from "../models/event";
28import {logger} from '../logger';
29
30/**
31 * This is an internal module. See {@link IndexedDBStore} for the public class.
32 * @module store/indexeddb
33 */
34
35// If this value is too small we'll be writing very often which will cause
36// noticable stop-the-world pauses. If this value is too big we'll be writing
37// so infrequently that the /sync size gets bigger on reload. Writing more
38// often does not affect the length of the pause since the entire /sync
39// response is persisted each time.
40const WRITE_DELAY_MS = 1000 * 60 * 5; // once every 5 minutes
41
42
43/**
44 * Construct a new Indexed Database store, which extends MemoryStore.
45 *
46 * This store functions like a MemoryStore except it periodically persists
47 * the contents of the store to an IndexedDB backend.
48 *
49 * All data is still kept in-memory but can be loaded from disk by calling
50 * <code>startup()</code>. This can make startup times quicker as a complete
51 * sync from the server is not required. This does not reduce memory usage as all
52 * the data is eagerly fetched when <code>startup()</code> is called.
53 * <pre>
54 * let opts = { localStorage: window.localStorage };
55 * let store = new IndexedDBStore();
56 * await store.startup(); // load from indexed db
57 * let client = sdk.createClient({
58 * store: store,
59 * });
60 * client.startClient();
61 * client.on("sync", function(state, prevState, data) {
62 * if (state === "PREPARED") {
63 * console.log("Started up, now with go faster stripes!");
64 * }
65 * });
66 * </pre>
67 *
68 * @constructor
69 * @extends MemoryStore
70 * @param {Object} opts Options object.
71 * @param {Object} opts.indexedDB The Indexed DB interface e.g.
72 * <code>window.indexedDB</code>
73 * @param {string=} opts.dbName Optional database name. The same name must be used
74 * to open the same database.
75 * @param {string=} opts.workerScript Optional URL to a script to invoke a web
76 * worker with to run IndexedDB queries on the web worker. The IndexedDbStoreWorker
77 * class is provided for this purpose and requires the application to provide a
78 * trivial wrapper script around it.
79 * @param {Object=} opts.workerApi The webWorker API object. If omitted, the global Worker
80 * object will be used if it exists.
81 * @prop {IndexedDBStoreBackend} backend The backend instance. Call through to
82 * this API if you need to perform specific indexeddb actions like deleting the
83 * database.
84 */
85export function IndexedDBStore(opts) {
86 MemoryStore.call(this, opts);
87
88 if (!opts.indexedDB) {
89 throw new Error('Missing required option: indexedDB');
90 }
91
92 if (opts.workerScript) {
93 // try & find a webworker-compatible API
94 let workerApi = opts.workerApi;
95 if (!workerApi) {
96 // default to the global Worker object (which is where it in a browser)
97 workerApi = global.Worker;
98 }
99 this.backend = new RemoteIndexedDBStoreBackend(
100 opts.workerScript, opts.dbName, workerApi,
101 );
102 } else {
103 this.backend = new LocalIndexedDBStoreBackend(opts.indexedDB, opts.dbName);
104 }
105
106 this.startedUp = false;
107 this._syncTs = 0;
108
109 // Records the last-modified-time of each user at the last point we saved
110 // the database, such that we can derive the set if users that have been
111 // modified since we last saved.
112 this._userModifiedMap = {
113 // user_id : timestamp
114 };
115}
116utils.inherits(IndexedDBStore, MemoryStore);
117utils.extend(IndexedDBStore.prototype, EventEmitter.prototype);
118
119IndexedDBStore.exists = function(indexedDB, dbName) {
120 return LocalIndexedDBStoreBackend.exists(indexedDB, dbName);
121};
122
123/**
124 * @return {Promise} Resolved when loaded from indexed db.
125 */
126IndexedDBStore.prototype.startup = function() {
127 if (this.startedUp) {
128 logger.log(`IndexedDBStore.startup: already started`);
129 return Promise.resolve();
130 }
131
132 logger.log(`IndexedDBStore.startup: connecting to backend`);
133 return this.backend.connect().then(() => {
134 logger.log(`IndexedDBStore.startup: loading presence events`);
135 return this.backend.getUserPresenceEvents();
136 }).then((userPresenceEvents) => {
137 logger.log(`IndexedDBStore.startup: processing presence events`);
138 userPresenceEvents.forEach(([userId, rawEvent]) => {
139 const u = new User(userId);
140 if (rawEvent) {
141 u.setPresenceEvent(new MatrixEvent(rawEvent));
142 }
143 this._userModifiedMap[u.userId] = u.getLastModifiedTime();
144 this.storeUser(u);
145 });
146 });
147};
148
149/**
150 * @return {Promise} Resolves with a sync response to restore the
151 * client state to where it was at the last save, or null if there
152 * is no saved sync data.
153 */
154IndexedDBStore.prototype.getSavedSync = degradable(function() {
155 return this.backend.getSavedSync();
156}, "getSavedSync");
157
158/** @return {Promise<bool>} whether or not the database was newly created in this session. */
159IndexedDBStore.prototype.isNewlyCreated = degradable(function() {
160 return this.backend.isNewlyCreated();
161}, "isNewlyCreated");
162
163/**
164 * @return {Promise} If there is a saved sync, the nextBatch token
165 * for this sync, otherwise null.
166 */
167IndexedDBStore.prototype.getSavedSyncToken = degradable(function() {
168 return this.backend.getNextBatchToken();
169}, "getSavedSyncToken"),
170
171/**
172 * Delete all data from this store.
173 * @return {Promise} Resolves if the data was deleted from the database.
174 */
175IndexedDBStore.prototype.deleteAllData = degradable(function() {
176 MemoryStore.prototype.deleteAllData.call(this);
177 return this.backend.clearDatabase().then(() => {
178 logger.log("Deleted indexeddb data.");
179 }, (err) => {
180 logger.error(`Failed to delete indexeddb data: ${err}`);
181 throw err;
182 });
183});
184
185/**
186 * Whether this store would like to save its data
187 * Note that obviously whether the store wants to save or
188 * not could change between calling this function and calling
189 * save().
190 *
191 * @return {boolean} True if calling save() will actually save
192 * (at the time this function is called).
193 */
194IndexedDBStore.prototype.wantsSave = function() {
195 const now = Date.now();
196 return now - this._syncTs > WRITE_DELAY_MS;
197};
198
199/**
200 * Possibly write data to the database.
201 *
202 * @param {bool} force True to force a save to happen
203 * @return {Promise} Promise resolves after the write completes
204 * (or immediately if no write is performed)
205 */
206IndexedDBStore.prototype.save = function(force) {
207 if (force || this.wantsSave()) {
208 return this._reallySave();
209 }
210 return Promise.resolve();
211};
212
213IndexedDBStore.prototype._reallySave = degradable(function() {
214 this._syncTs = Date.now(); // set now to guard against multi-writes
215
216 // work out changed users (this doesn't handle deletions but you
217 // can't 'delete' users as they are just presence events).
218 const userTuples = [];
219 for (const u of this.getUsers()) {
220 if (this._userModifiedMap[u.userId] === u.getLastModifiedTime()) continue;
221 if (!u.events.presence) continue;
222
223 userTuples.push([u.userId, u.events.presence.event]);
224
225 // note that we've saved this version of the user
226 this._userModifiedMap[u.userId] = u.getLastModifiedTime();
227 }
228
229 return this.backend.syncToDatabase(userTuples);
230});
231
232IndexedDBStore.prototype.setSyncData = degradable(function(syncData) {
233 return this.backend.setSyncData(syncData);
234}, "setSyncData");
235
236/**
237 * Returns the out-of-band membership events for this room that
238 * were previously loaded.
239 * @param {string} roomId
240 * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
241 * @returns {null} in case the members for this room haven't been stored yet
242 */
243IndexedDBStore.prototype.getOutOfBandMembers = degradable(function(roomId) {
244 return this.backend.getOutOfBandMembers(roomId);
245}, "getOutOfBandMembers");
246
247/**
248 * Stores the out-of-band membership events for this room. Note that
249 * it still makes sense to store an empty array as the OOB status for the room is
250 * marked as fetched, and getOutOfBandMembers will return an empty array instead of null
251 * @param {string} roomId
252 * @param {event[]} membershipEvents the membership events to store
253 * @returns {Promise} when all members have been stored
254 */
255IndexedDBStore.prototype.setOutOfBandMembers = degradable(function(
256 roomId,
257 membershipEvents,
258) {
259 MemoryStore.prototype.setOutOfBandMembers.call(this, roomId, membershipEvents);
260 return this.backend.setOutOfBandMembers(roomId, membershipEvents);
261}, "setOutOfBandMembers");
262
263IndexedDBStore.prototype.clearOutOfBandMembers = degradable(function(roomId) {
264 MemoryStore.prototype.clearOutOfBandMembers.call(this);
265 return this.backend.clearOutOfBandMembers(roomId);
266}, "clearOutOfBandMembers");
267
268IndexedDBStore.prototype.getClientOptions = degradable(function() {
269 return this.backend.getClientOptions();
270}, "getClientOptions");
271
272IndexedDBStore.prototype.storeClientOptions = degradable(function(options) {
273 MemoryStore.prototype.storeClientOptions.call(this, options);
274 return this.backend.storeClientOptions(options);
275}, "storeClientOptions");
276
277/**
278 * All member functions of `IndexedDBStore` that access the backend use this wrapper to
279 * watch for failures after initial store startup, including `QuotaExceededError` as
280 * free disk space changes, etc.
281 *
282 * When IndexedDB fails via any of these paths, we degrade this back to a `MemoryStore`
283 * in place so that the current operation and all future ones are in-memory only.
284 *
285 * @param {Function} func The degradable work to do.
286 * @param {String} fallback The method name for fallback.
287 * @returns {Function} A wrapped member function.
288 */
289function degradable(func, fallback) {
290 return async function(...args) {
291 try {
292 return await func.call(this, ...args);
293 } catch (e) {
294 logger.error("IndexedDBStore failure, degrading to MemoryStore", e);
295 this.emit("degraded", e);
296 try {
297 // We try to delete IndexedDB after degrading since this store is only a
298 // cache (the app will still function correctly without the data).
299 // It's possible that deleting repair IndexedDB for the next app load,
300 // potenially by making a little more space available.
301 logger.log("IndexedDBStore trying to delete degraded data");
302 await this.backend.clearDatabase();
303 logger.log("IndexedDBStore delete after degrading succeeeded");
304 } catch (e) {
305 logger.warn("IndexedDBStore delete after degrading failed", e);
306 }
307 // Degrade the store from being an instance of `IndexedDBStore` to instead be
308 // an instance of `MemoryStore` so that future API calls use the memory path
309 // directly and skip IndexedDB entirely. This should be safe as
310 // `IndexedDBStore` already extends from `MemoryStore`, so we are making the
311 // store become its parent type in a way. The mutator methods of
312 // `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are
313 // not overridden at all).
314 Object.setPrototypeOf(this, MemoryStore.prototype);
315 if (fallback) {
316 return await MemoryStore.prototype[fallback].call(this, ...args);
317 }
318 }
319 };
320}