1 | /*
|
2 | Copyright 2017 Vector Creations Ltd
|
3 | Copyright 2018 New Vector Ltd
|
4 | Copyright 2019 The Matrix.org Foundation C.I.C.
|
5 |
|
6 | Licensed under the Apache License, Version 2.0 (the "License");
|
7 | you may not use this file except in compliance with the License.
|
8 | You may obtain a copy of the License at
|
9 |
|
10 | http://www.apache.org/licenses/LICENSE-2.0
|
11 |
|
12 | Unless required by applicable law or agreed to in writing, software
|
13 | distributed under the License is distributed on an "AS IS" BASIS,
|
14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15 | See the License for the specific language governing permissions and
|
16 | limitations under the License.
|
17 | */
|
18 |
|
19 | /* eslint-disable babel/no-invalid-this */
|
20 |
|
21 | import {MemoryStore} from "./memory";
|
22 | import * as utils from "../utils";
|
23 | import {EventEmitter} from 'events';
|
24 | import {LocalIndexedDBStoreBackend} from "./indexeddb-local-backend.js";
|
25 | import {RemoteIndexedDBStoreBackend} from "./indexeddb-remote-backend.js";
|
26 | import {User} from "../models/user";
|
27 | import {MatrixEvent} from "../models/event";
|
28 | import {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.
|
40 | const 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 | */
|
85 | export 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 | }
|
116 | utils.inherits(IndexedDBStore, MemoryStore);
|
117 | utils.extend(IndexedDBStore.prototype, EventEmitter.prototype);
|
118 |
|
119 | IndexedDBStore.exists = function(indexedDB, dbName) {
|
120 | return LocalIndexedDBStoreBackend.exists(indexedDB, dbName);
|
121 | };
|
122 |
|
123 | /**
|
124 | * @return {Promise} Resolved when loaded from indexed db.
|
125 | */
|
126 | IndexedDBStore.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 | */
|
154 | IndexedDBStore.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. */
|
159 | IndexedDBStore.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 | */
|
167 | IndexedDBStore.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 | */
|
175 | IndexedDBStore.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 | */
|
194 | IndexedDBStore.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 | */
|
206 | IndexedDBStore.prototype.save = function(force) {
|
207 | if (force || this.wantsSave()) {
|
208 | return this._reallySave();
|
209 | }
|
210 | return Promise.resolve();
|
211 | };
|
212 |
|
213 | IndexedDBStore.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 |
|
232 | IndexedDBStore.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 | */
|
243 | IndexedDBStore.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 | */
|
255 | IndexedDBStore.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 |
|
263 | IndexedDBStore.prototype.clearOutOfBandMembers = degradable(function(roomId) {
|
264 | MemoryStore.prototype.clearOutOfBandMembers.call(this);
|
265 | return this.backend.clearOutOfBandMembers(roomId);
|
266 | }, "clearOutOfBandMembers");
|
267 |
|
268 | IndexedDBStore.prototype.getClientOptions = degradable(function() {
|
269 | return this.backend.getClientOptions();
|
270 | }, "getClientOptions");
|
271 |
|
272 | IndexedDBStore.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 | */
|
289 | function 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 | }
|