Files
notesnook/packages/core/api/sync/index.js

435 lines
12 KiB
JavaScript
Raw Normal View History

/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
2022-08-30 16:13:11 +05:00
2021-10-26 23:06:52 +05:00
import {
EV,
EVENTS,
sendAttachmentsProgressEvent,
sendSyncProgressEvent
2021-10-26 23:06:52 +05:00
} from "../../common";
import Constants from "../../utils/constants";
2020-12-16 12:06:25 +05:00
import TokenManager from "../token-manager";
import Collector from "./collector";
2022-02-08 13:16:41 +05:00
import * as signalr from "@microsoft/signalr";
2022-03-30 15:52:48 +05:00
import Merger from "./merger";
import Conflicts from "./conflicts";
import { AutoSync } from "./auto-sync";
import { toChunks } from "../../utils/array";
2022-03-31 16:18:34 +05:00
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
import { logger } from "../../logger";
2022-02-10 16:10:54 +05:00
2022-03-30 15:52:48 +05:00
/**
* @typedef {{
* item: string,
* itemType: string,
* lastSynced: number,
* current: number,
* total: number,
* synced?: boolean
* }} SyncTransferItem
*/
/**
* @typedef {{
* items: string[],
* types: string[],
* lastSynced: number,
* current: number,
* total: number,
* }} BatchedSyncTransferItem
*/
export default class SyncManager {
2020-04-09 16:36:57 +05:00
/**
*
* @param {import("../index").default} db
2020-04-09 16:36:57 +05:00
*/
constructor(db) {
2022-03-30 15:52:48 +05:00
this.sync = new Sync(db);
}
2022-03-30 20:45:16 +05:00
async start(full, force) {
2022-07-09 21:24:18 +05:00
try {
2022-03-30 20:45:16 +05:00
await this.sync.autoSync.start();
2022-07-09 21:24:18 +05:00
await this.sync.start(full, force);
return true;
} catch (e) {
var isHubException = e.message.includes("HubException:");
if (isHubException) {
var actualError = /HubException: (.*)/gm.exec(e.message);
if (actualError.length > 1) throw new Error(actualError[1]);
}
2022-07-09 21:24:18 +05:00
throw e;
}
2022-03-30 15:52:48 +05:00
}
async acquireLock(callback) {
2022-07-09 21:24:18 +05:00
try {
this.sync.autoSync.stop();
await callback();
} finally {
2022-07-20 17:39:02 +05:00
await this.sync.autoSync.start();
2022-07-09 21:24:18 +05:00
}
2022-03-30 15:52:48 +05:00
}
async stop() {
await this.sync.cancel();
}
}
class Sync {
/**
*
* @param {import("../index").default} db
*/
constructor(db) {
this.db = db;
this.conflicts = new Conflicts(db);
this.collector = new Collector(db);
this.merger = new Merger(db);
this.autoSync = new AutoSync(db, 1000);
this.logger = logger.scope("Sync");
2022-03-30 15:52:48 +05:00
const tokenManager = new TokenManager(db.storage);
this.connection = new signalr.HubConnectionBuilder()
2022-02-08 13:16:41 +05:00
.withUrl(`${Constants.API_HOST}/hubs/sync`, {
2022-03-30 15:52:48 +05:00
accessTokenFactory: () => tokenManager.getAccessToken(),
skipNegotiation: true,
transport: signalr.HttpTransportType.WebSockets,
logger: {
log(level, message) {
const scopedLogger = logger.scope("SignalR::SyncHub");
switch (level) {
case signalr.LogLevel.Critical:
return scopedLogger.fatal(new Error(message));
case signalr.LogLevel.Debug:
return scopedLogger.debug(message);
case signalr.LogLevel.Error:
return scopedLogger.error(new Error(message));
case signalr.LogLevel.Information:
return scopedLogger.info(message);
case signalr.LogLevel.None:
return scopedLogger.log(message);
case signalr.LogLevel.Trace:
return scopedLogger.log(message);
case signalr.LogLevel.Warning:
return scopedLogger.warn(message);
}
}
}
2022-02-08 13:16:41 +05:00
})
2022-03-31 16:18:34 +05:00
.withHubProtocol(new MessagePackHubProtocol({ ignoreUndefined: true }))
.withAutomaticReconnect()
2022-02-08 13:16:41 +05:00
.build();
2022-02-10 16:10:54 +05:00
2022-03-28 10:54:13 +05:00
EV.subscribe(EVENTS.userLoggedOut, async () => {
2022-03-30 15:52:48 +05:00
await this.connection.stop();
this.autoSync.stop();
2022-03-28 10:54:13 +05:00
});
2022-03-30 15:52:48 +05:00
this.connection.on("SyncItem", async (syncStatus) => {
2022-03-31 00:08:21 +05:00
await this.onSyncItem(syncStatus);
sendSyncProgressEvent(
this.db.eventManager,
"download",
syncStatus.total,
syncStatus.current
);
2022-03-30 15:52:48 +05:00
});
2022-03-28 15:00:27 +05:00
this.connection.on("RemoteSyncCompleted", (lastSynced) => {
this.onRemoteSyncCompleted(lastSynced);
2022-03-31 00:08:21 +05:00
});
2022-03-30 15:52:48 +05:00
}
2022-03-28 15:00:27 +05:00
2022-03-30 15:52:48 +05:00
/**
*
* @param {boolean} full
* @param {boolean} force
* @param {number} serverLastSynced
2022-03-30 15:52:48 +05:00
*/
async start(full, force, serverLastSynced) {
this.logger.info("Starting sync", { full, force, serverLastSynced });
this.connection.onclose((error) => {
this.logger.error(error || new Error("Connection closed."));
2022-03-30 15:52:48 +05:00
throw new Error("Connection closed.");
});
2020-04-09 16:36:57 +05:00
2022-03-30 15:52:48 +05:00
const { lastSynced, oldLastSynced } = await this.init(force);
this.logger.info("Initialized sync", { lastSynced, oldLastSynced });
2021-10-26 23:06:52 +05:00
2022-03-31 09:40:51 +05:00
const { newLastSynced, data } = await this.collect(lastSynced, force);
this.logger.info("Data collected for sync", {
newLastSynced,
2022-07-20 07:19:48 +05:00
length: data.items.length,
isEmpty: data.items.length <= 0
});
2022-03-30 20:45:16 +05:00
const serverResponse = full ? await this.fetch(lastSynced) : null;
this.logger.info("Data fetched", serverResponse);
2022-03-30 15:52:48 +05:00
if (await this.send(data, newLastSynced)) {
this.logger.info("New data sent");
2022-03-30 15:52:48 +05:00
await this.stop(newLastSynced);
} else if (serverResponse) {
this.logger.info("No new data to send.");
2022-03-30 15:52:48 +05:00
await this.stop(serverResponse.lastSynced);
} else {
this.logger.info("Nothing to do.");
await this.stop(serverLastSynced || oldLastSynced);
2022-03-30 15:52:48 +05:00
}
}
2022-03-30 15:52:48 +05:00
async init(isForceSync) {
await this.checkConnection();
2022-03-30 15:52:48 +05:00
await this.conflicts.recalculate();
if (await this.conflicts.check()) {
throw new Error(
"Merge conflicts detected. Please resolve all conflicts to continue syncing."
);
}
2022-02-08 13:16:41 +05:00
let lastSynced = await this.db.lastSynced();
2022-03-30 15:52:48 +05:00
if (isForceSync) lastSynced = 0;
2022-02-08 13:16:41 +05:00
2022-03-30 15:52:48 +05:00
const oldLastSynced = lastSynced;
return { lastSynced, oldLastSynced };
}
2022-02-10 16:10:54 +05:00
2022-03-30 15:52:48 +05:00
async fetch(lastSynced) {
await this.checkConnection();
2022-03-30 15:52:48 +05:00
const serverResponse = await new Promise((resolve, reject) => {
2022-04-01 14:50:12 +05:00
let counter = { count: 0, queue: null };
2022-03-30 15:52:48 +05:00
this.connection.stream("FetchItems", lastSynced).subscribe({
2022-03-31 16:18:34 +05:00
next: (/** @type {SyncTransferItem} */ syncStatus) => {
2022-03-31 00:08:21 +05:00
const { total, item, synced, lastSynced } = syncStatus;
2022-04-01 10:38:43 +05:00
if (synced) {
resolve({ synced, lastSynced });
return;
}
if (!item) return;
2022-04-01 14:50:12 +05:00
if (counter.queue === null) counter.queue = total;
2022-03-31 00:08:21 +05:00
2022-03-31 17:14:09 +05:00
this.onSyncItem(syncStatus)
.then(() => {
2022-03-31 16:18:34 +05:00
sendSyncProgressEvent(
this.db.eventManager,
`download`,
total,
2022-04-01 14:50:12 +05:00
++counter.count
2022-03-31 16:18:34 +05:00
);
2022-03-31 17:14:09 +05:00
})
.catch(reject)
.finally(() => {
if (--counter.queue <= 0) resolve({ synced, lastSynced });
2022-03-31 16:18:34 +05:00
});
2022-03-30 15:52:48 +05:00
},
2022-03-31 00:08:21 +05:00
complete: () => {},
error: reject
2022-02-08 13:16:41 +05:00
});
2022-03-30 15:52:48 +05:00
});
2022-02-08 13:16:41 +05:00
2022-03-30 15:52:48 +05:00
if (await this.conflicts.check()) {
throw new Error(
"Merge conflicts detected. Please resolve all conflicts to continue syncing."
);
2022-02-08 13:16:41 +05:00
}
2022-03-31 00:08:21 +05:00
return serverResponse;
2022-03-30 15:52:48 +05:00
}
2022-03-31 09:40:51 +05:00
async collect(lastSynced, force) {
2022-03-30 15:52:48 +05:00
const newLastSynced = Date.now();
2022-07-20 07:19:48 +05:00
const data = await this.collector.collect(lastSynced, force);
2022-03-30 15:52:48 +05:00
return { newLastSynced, data };
}
/**
*
2022-07-20 07:19:48 +05:00
* @param {{ items: any[]; vaultKey: any; }} data
2022-03-30 15:52:48 +05:00
* @param {number} lastSynced
* @returns {Promise<boolean>}
*/
async send(data, lastSynced) {
await this.uploadAttachments();
2022-07-20 07:19:48 +05:00
if (data.items.length <= 0) return false;
2022-02-08 13:16:41 +05:00
2022-07-20 07:19:48 +05:00
const arrays = data.items.reduce(
(arrays, item) => {
arrays.types.push(item.collectionId);
delete item.collectionId;
2022-07-20 07:19:48 +05:00
arrays.items.push(item);
2022-03-30 15:52:48 +05:00
return arrays;
},
2022-07-20 07:19:48 +05:00
{ items: [], types: [] }
2022-03-30 15:52:48 +05:00
);
2022-02-08 13:16:41 +05:00
2022-03-30 15:52:48 +05:00
if (data.vaultKey) {
arrays.types.push("vaultKey");
2022-07-20 07:19:48 +05:00
arrays.items.push(data.vaultKey);
}
2022-07-20 07:19:48 +05:00
let total = arrays.items.length;
2022-03-31 16:18:34 +05:00
2022-03-30 15:52:48 +05:00
arrays.types = toChunks(arrays.types, 30);
arrays.items = toChunks(arrays.items, 30);
let done = 0;
2022-07-20 07:19:48 +05:00
for (let i = 0; i < arrays.items.length; ++i) {
this.logger.info(`Sending batch ${done}/${total}`);
2022-07-20 07:19:48 +05:00
const items = (await this.collector.encrypt(arrays.items[i])).map(
(item) => JSON.stringify(item)
);
2022-03-30 15:52:48 +05:00
const types = arrays.types[i];
const result = await this.sendBatchToServer({
lastSynced,
current: i,
2022-03-30 15:52:48 +05:00
total,
items,
types
2022-03-30 15:52:48 +05:00
});
if (result) {
2022-07-20 07:19:48 +05:00
done += items.length;
sendSyncProgressEvent(this.db.eventManager, "upload", total, done);
this.logger.info(`Batch sent (${done}/${total})`);
} else {
this.logger.error(
new Error(`Failed to send batch. Server returned falsy response.`)
);
2022-03-30 15:52:48 +05:00
}
}
2022-03-30 20:45:16 +05:00
return await this.connection.invoke("SyncCompleted", lastSynced);
}
2022-03-30 15:52:48 +05:00
async stop(lastSynced) {
this.logger.info("Stopping sync", { lastSynced });
2022-03-30 20:45:16 +05:00
const storedLastSynced = await this.db.lastSynced();
if (lastSynced > storedLastSynced)
await this.db.storage.write("lastSynced", lastSynced);
2022-03-30 15:52:48 +05:00
this.db.eventManager.publish(EVENTS.syncCompleted);
2020-04-09 16:36:57 +05:00
}
2021-09-20 12:10:36 +05:00
2022-03-30 15:52:48 +05:00
async cancel() {
2022-07-20 07:19:48 +05:00
this.logger.info("Sync canceled");
2022-03-30 15:52:48 +05:00
await this.connection.stop();
}
2022-03-30 15:52:48 +05:00
/**
* @private
*/
async uploadAttachments() {
const attachments = this.db.attachments.pending;
this.logger.info("Uploading attachments...", { total: attachments.length });
for (var i = 0; i < attachments.length; ++i) {
const attachment = attachments[i];
2022-02-28 13:05:51 +05:00
const { hash } = attachment.metadata;
sendAttachmentsProgressEvent("upload", hash, attachments.length, i);
2021-09-20 12:10:36 +05:00
try {
2022-03-30 15:52:48 +05:00
const isUploaded = await this.db.fs.uploadFile(hash, hash);
if (!isUploaded) throw new Error("Failed to upload file.");
2021-09-20 12:10:36 +05:00
2022-03-30 15:52:48 +05:00
await this.db.attachments.markAsUploaded(attachment.id);
} catch (e) {
2022-07-20 07:19:48 +05:00
logger.error(e, { attachment });
2022-02-28 13:05:51 +05:00
const error = e.message;
2022-03-30 15:52:48 +05:00
await this.db.attachments.markAsFailed(attachment.id, error);
}
}
sendAttachmentsProgressEvent("upload", null, attachments.length);
2021-09-20 12:10:36 +05:00
}
2021-10-26 23:06:52 +05:00
2022-02-10 16:10:54 +05:00
/**
2022-03-30 15:52:48 +05:00
* @private
2022-02-10 16:10:54 +05:00
*/
async onRemoteSyncCompleted(lastSynced) {
// refresh monographs on sync completed
await this.db.monographs.init();
// refresh topic references
this.db.notes.topicReferences.rebuild();
await this.start(false, false, lastSynced);
2022-02-10 16:10:54 +05:00
}
2022-03-30 15:52:48 +05:00
/**
* @param {SyncTransferItem} syncStatus
* @private
*/
2022-03-31 00:08:21 +05:00
onSyncItem(syncStatus) {
const { item: itemJSON, itemType } = syncStatus;
2022-03-30 15:52:48 +05:00
const item = JSON.parse(itemJSON);
2022-02-10 16:10:54 +05:00
2022-03-31 00:08:21 +05:00
return this.merger.mergeItem(itemType, item);
2022-02-10 16:10:54 +05:00
}
/**
*
2022-03-30 15:52:48 +05:00
* @param {BatchedSyncTransferItem} batch
* @returns {Promise<boolean>}
* @private
2022-02-10 16:10:54 +05:00
*/
2022-03-30 15:52:48 +05:00
async sendBatchToServer(batch) {
if (!batch) return false;
await this.checkConnection();
2022-03-30 15:52:48 +05:00
const result = await this.connection.invoke("SyncItem", batch);
return result === 1;
2022-02-10 16:10:54 +05:00
}
async checkConnection() {
try {
if (this.connection.state !== signalr.HubConnectionState.Connected) {
if (this.connection.state !== signalr.HubConnectionState.Disconnected) {
await this.connection.stop();
}
await promiseTimeout(15000, this.connection.start());
}
} catch (e) {
this.connection.stop();
this.logger.warn(e.message);
throw new Error(
"Could not connect to the Sync server. Please try again."
);
}
}
2020-04-09 16:36:57 +05:00
}
function promiseTimeout(ms, promise) {
// Create a promise that rejects in <ms> milliseconds
let timeout = new Promise((resolve, reject) => {
let id = setTimeout(() => {
clearTimeout(id);
reject(new Error("Sync timed out in " + ms + "ms."));
}, ms);
});
// Returns a race between our timeout and the passed in promise
return Promise.race([promise, timeout]);
}