git-documentdb
Version:
Offline-first database that syncs with Git
1,064 lines • 43.8 kB
JavaScript
"use strict";
/**
* GitDocumentDB
* Copyright (c) Hidekazu Kubota
*
* This source code is licensed under the Mozilla Public License Version 2.0
* found in the LICENSE file in the root directory of this source tree.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Sync = exports.syncImpl = exports.syncAndGetResultImpl = exports.encodeToGitRemoteName = void 0;
/**
* ! Must import both clearInterval and setInterval from 'timers'
*/
const timers_1 = require("timers");
const crypto_1 = __importDefault(require("crypto"));
const isomorphic_git_1 = __importDefault(require("isomorphic-git"));
const fs_extra_1 = __importDefault(require("fs-extra"));
const remote_isomorphic_git_1 = require("../plugin/remote-isomorphic-git");
const utils_1 = require("../utils");
const error_1 = require("../error");
const sync_worker_1 = require("./sync_worker");
const push_worker_1 = require("./push_worker");
const remote_repository_1 = require("./remote_repository");
const const_1 = require("../const");
const json_diff_1 = require("./json_diff");
const json_patch_ot_1 = require("./json_patch_ot");
const combine_1 = require("./combine");
const validator_1 = require("../validator");
const remote_engine_1 = require("./remote_engine");
/**
* encodeToGitRemoteName
*
* @remarks
* The first default name of Git remote is "origin".
*
* GitDocumentDB adds an alias of "origin",
* whose name is generated automatically by this function.
* The second and subsequent remotes are also named in the same way.
*
* A remote name consists of [remote address]_[hash].
* Periods are replaced with underscores.
* e.g.) github_com_a0b1c23
* It is human-readable.
*
* [remote address] is [hostname + domain name] or [ip address].
* [hash] is calculated from remoteURL.
*
* [hash] is the first seven characters of SHA-1 so that it may collide.
* Capitalize one of the remote addresses when hashes collide
* because a hostname and a domain name are not case sensitive.
*
* @throws {@link RemoteErr.InvalidURLFormatError}
*
* @public
*/
function encodeToGitRemoteName(remoteURL) {
let host;
if (/:\/\/.+?@(.+?):/.test(remoteURL)) {
// ssh://user@foo.bar:xxx/path/repos.git
host = RegExp.$1;
}
else if (/:\/\/(.+?):/.test(remoteURL)) {
// http://foo.bar:xxx/path/repos.git
host = RegExp.$1;
}
else if (/:\/\/.+?@(.+?)\//.test(remoteURL)) {
// ssh://user@foo.bar/path/repos.git
host = RegExp.$1;
}
else if (/:\/\/(.+?)\//.test(remoteURL)) {
// http://foo.bar/user/repos.git
host = RegExp.$1;
}
else if (/^.+?@(.+?):/.test(remoteURL)) {
// user@foo.bar:path/repos.git
host = RegExp.$1;
}
else {
throw new remote_engine_1.RemoteErr.InvalidURLFormatError(`URL format is invalid: ${remoteURL}`);
}
const shortHash = crypto_1.default.createHash('sha1').update(remoteURL).digest('hex').substr(0, 7);
// Use toLowerCase() because git.setConfig() and git.addRemote() automatically converts the path to lowercase.
return host.toLowerCase().replace(/\./g, '_') + '_' + shortHash;
}
exports.encodeToGitRemoteName = encodeToGitRemoteName;
/**
* Implementation of GitDocumentDB#sync(options, get_sync_result)
*
* @throws {@link Err.DatabaseClosingError}
* @throws {@link Err.RepositoryNotOpenError}
*
* @throws # Errors from constructor of {@link Sync} class.
* @throws # Errors from {@link Sync.init}
*
* @internal
*/
async function syncAndGetResultImpl(options) {
if (this.isClosing) {
throw new error_1.Err.DatabaseClosingError();
}
if (!this.isOpened) {
return Promise.reject(new error_1.Err.RepositoryNotOpenError());
}
const sync = new Sync(this, options);
const syncResult = await sync.init();
return [sync, syncResult];
}
exports.syncAndGetResultImpl = syncAndGetResultImpl;
/**
* Implementation of GitDocumentDB#sync(options)
*
* @throws {@link Err.DatabaseClosingError}
* @throws {@link Err.RepositoryNotOpenError}
*
* @throws # Errors from constructor of {@link Sync} class.
* @throws # Errors from {@link Sync.init}
*
* @internal
*/
async function syncImpl(options) {
const [sync, syncResult] = await syncAndGetResultImpl.call(this, options);
return sync;
}
exports.syncImpl = syncImpl;
/**
* Filter file changes by collectionPath
*
* @internal
*/
function filterChanges(syncResult, collectionPath) {
if (collectionPath === '') {
return syncResult;
}
const filter = (changedFiles) => {
// eslint-disable-next-line complexity
return changedFiles.reduce((result, changedFile) => {
let oldFatDoc;
let newFatDoc;
if (changedFile.operation === 'delete' || changedFile.operation === 'update') {
oldFatDoc = changedFile.old;
}
if (changedFile.operation === 'insert' || changedFile.operation === 'update') {
// insert
newFatDoc = changedFile.new;
}
if ((oldFatDoc && oldFatDoc.name.startsWith(collectionPath)) ||
(newFatDoc && newFatDoc.name.startsWith(collectionPath))) {
if (oldFatDoc) {
oldFatDoc.name = oldFatDoc.name.replace(new RegExp('^' + collectionPath), '');
if (oldFatDoc.type === 'json') {
oldFatDoc._id = oldFatDoc._id.replace(new RegExp('^' + collectionPath), '');
oldFatDoc.doc._id = oldFatDoc._id;
}
}
if (newFatDoc) {
newFatDoc.name = newFatDoc.name.replace(new RegExp('^' + collectionPath), '');
if (newFatDoc.type === 'json') {
newFatDoc._id = newFatDoc._id.replace(new RegExp('^' + collectionPath), '');
newFatDoc.doc._id = newFatDoc._id;
}
}
result.push(changedFile);
}
return result;
}, []);
};
if (syncResult.action === 'resolve conflicts and push' ||
syncResult.action === 'merge and push' ||
syncResult.action === 'resolve conflicts and push error' ||
syncResult.action === 'merge and push error' ||
syncResult.action === 'fast-forward merge') {
syncResult.changes.local = filter(syncResult.changes.local);
}
if (syncResult.action === 'resolve conflicts and push' ||
syncResult.action === 'merge and push' ||
syncResult.action === 'push') {
syncResult.changes.remote = filter(syncResult.changes.remote);
}
return syncResult;
}
/**
* Synchronizer class
*
* @public
*/
class Sync {
/**
* constructor
*
* @throws {@link Err.UndefinedRemoteURLError}
* @throws {@link Err.IntervalTooSmallError}
* @throws {@link Err.SyncIntervalLessThanOrEqualToRetryIntervalError}
*
* @throws # Errors from encodeToGitRemoteName
* @throws - {@link RemoteErr.InvalidURLFormatError}
*
* @public
*/
// eslint-disable-next-line complexity
constructor(gitDDB, options) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
var _l, _m, _o, _p, _q, _r, _s, _t, _u, _v;
this._retrySyncCounter = 0; // Decremental count
this._isClosed = false;
/**
* Remote Engine
*/
this._engine = remote_isomorphic_git_1.name;
this._remoteName = '';
/***********************************************
* Public properties
***********************************************/
/**
* runBeforeLiveSync
*
* This function is executed just before each automated(live) synchronization event is queued.
* Set undefined to stop it.
*/
this.runBeforeLiveSync = undefined;
/**
* SyncEvent handlers
*
* @internal
*/
this.eventHandlers = {
change: [],
localChange: [],
remoteChange: [],
combine: [],
pause: [],
resume: [],
start: [],
complete: [],
error: [],
};
this._gitDDB = gitDDB;
options !== null && options !== void 0 ? options : (options = {
remoteUrl: undefined,
live: undefined,
syncDirection: undefined,
interval: undefined,
retry: undefined,
retryInterval: undefined,
connection: undefined,
combineDbStrategy: undefined,
includeCommits: undefined,
conflictResolutionStrategy: undefined,
});
// Deep clone
this._options = JSON.parse(JSON.stringify(options));
// Set function again
this._options.conflictResolutionStrategy = options.conflictResolutionStrategy;
if (this._options.remoteUrl === undefined || this._options.remoteUrl === '') {
throw new error_1.Err.UndefinedRemoteURLError();
}
(_a = (_l = this._options).live) !== null && _a !== void 0 ? _a : (_l.live = false);
(_b = (_m = this._options).syncDirection) !== null && _b !== void 0 ? _b : (_m.syncDirection = 'both');
(_c = (_o = this._options).interval) !== null && _c !== void 0 ? _c : (_o.interval = const_1.DEFAULT_SYNC_INTERVAL);
(_d = (_p = this._options).retryInterval) !== null && _d !== void 0 ? _d : (_p.retryInterval = const_1.NETWORK_RETRY_INTERVAL);
if (this._options.interval < const_1.MINIMUM_SYNC_INTERVAL) {
throw new error_1.Err.IntervalTooSmallError(const_1.MINIMUM_SYNC_INTERVAL, this._options.interval);
}
if (this._options.interval <= this._options.retryInterval) {
throw new error_1.Err.SyncIntervalLessThanOrEqualToRetryIntervalError(this._options.interval, this._options.retryInterval);
}
(_e = (_q = this._options).retry) !== null && _e !== void 0 ? _e : (_q.retry = const_1.NETWORK_RETRY);
(_f = (_r = this._options).combineDbStrategy) !== null && _f !== void 0 ? _f : (_r.combineDbStrategy = const_1.DEFAULT_COMBINE_DB_STRATEGY);
(_g = (_s = this._options).includeCommits) !== null && _g !== void 0 ? _g : (_s.includeCommits = false);
(_h = (_t = this._options).conflictResolutionStrategy) !== null && _h !== void 0 ? _h : (_t.conflictResolutionStrategy = const_1.DEFAULT_CONFLICT_RESOLUTION_STRATEGY);
this.jsonDiff = new json_diff_1.JsonDiff(gitDDB.schema.json);
this.jsonPatch = new json_patch_ot_1.JsonPatchOT(gitDDB.schema.json);
(_j = (_u = this._options).connection) !== null && _j !== void 0 ? _j : (_u.connection = { type: 'none' });
(_k = (_v = this._options.connection).engine) !== null && _k !== void 0 ? _k : (_v.engine = remote_isomorphic_git_1.name);
this._remoteRepository = new remote_repository_1.RemoteRepository({
remoteUrl: this._options.remoteUrl,
connection: this._options.connection,
});
this._remoteName = encodeToGitRemoteName(this.remoteURL);
this._engine = this._options.connection.engine;
}
/***********************************************
* Public properties (readonly)
***********************************************/
/**
* remoteURL
*
* @readonly
* @public
*/
get remoteURL() {
return this._options.remoteUrl;
}
get engine() {
return this._engine;
}
/**
* Remote repository
*
* @readonly
* @public
*/
get remoteRepository() {
return this._remoteRepository;
}
/**
* Get a clone of remote options
*
* @readonly
* @public
*/
get options() {
const newOptions = JSON.parse(JSON.stringify(this._options));
// options include function.
newOptions.conflictResolutionStrategy = this._options.conflictResolutionStrategy;
return newOptions;
}
/**
* remoteName
*
* @readonly
* @public
*/
get remoteName() {
return this._remoteName;
}
/***********************************************
* Public methods
***********************************************/
/**
* Initialize remote connection
*
* @remarks
* Call init() once just after creating an instance.
*
* @throws {@link Err.CannotCreateRemoteRepositoryError}
*
* @throws # Errors from RemoteEngine[engineName].checkFetch
* @throws - {@link RemoteErr.InvalidGitRemoteError}
* @throws - {@link RemoteErr.InvalidURLFormatError}
* @throws - {@link RemoteErr.NetworkError}
* @throws - {@link RemoteErr.HTTPError401AuthorizationRequired}
* @throws - {@link RemoteErr.HTTPError404NotFound}
* @throws - {@link RemoteErr.CannotConnectError}
* @throws - {@link RemoteErr.InvalidURLFormatError}
* @throws - {@link RemoteErr.InvalidRepositoryURLError}
* @throws - {@link RemoteErr.InvalidSSHKeyPathError}
* @throws - {@link RemoteErr.InvalidAuthenticationTypeError}
*
* @throws Errors from {@link Sync.trySync}
* @throws Errors from {@link Sync.tryPush}
*
* @public
*/
// eslint-disable-next-line complexity
async init() {
this._isClosed = false;
let isNewRemoteRepository = false;
const urlOfRemote = await isomorphic_git_1.default.getConfig({
fs: fs_extra_1.default,
dir: this._gitDDB.workingDir,
path: `remote.${this.remoteName}.url`,
});
if (urlOfRemote !== this.remoteURL) {
await isomorphic_git_1.default.addRemote({
fs: fs_extra_1.default,
dir: this._gitDDB.workingDir,
remote: this.remoteName,
url: this.remoteURL,
});
}
/**
* Set origin if not exist
*/
const originUrl = await isomorphic_git_1.default.getConfig({
fs: fs_extra_1.default,
dir: this._gitDDB.workingDir,
path: `remote.origin.url`,
});
if (originUrl === undefined) {
await isomorphic_git_1.default.addRemote({
fs: fs_extra_1.default,
dir: this._gitDDB.workingDir,
remote: 'origin',
url: this.remoteURL,
});
}
let remoteResult;
if (this._options.syncDirection === 'push') {
// checkFetch will return undefined if succeeds.
// Do not download remote.
// eslint-disable-next-line no-await-in-loop
remoteResult = await remote_engine_1.RemoteEngine[this._engine]
.checkFetch(this._gitDDB.workingDir, this._options, this.remoteName, this._gitDDB.tsLogger)
.catch(err => err);
}
else {
// fetch will return true if succeeds.
// eslint-disable-next-line no-await-in-loop
remoteResult = await remote_engine_1.RemoteEngine[this._engine]
.fetch(this._gitDDB.workingDir, this._options, this.remoteName, this._gitDDB.defaultBranch, this._gitDDB.defaultBranch, this._gitDDB.tsLogger)
.catch(err => err);
}
/**
* Do not use 'instanceof' to compare git-documentdb-remote-errors
* because an error from RemoteEngine plugin may not have the same prototype
* in its prototype chain.
* - https://nodejs.org/en/blog/npm/peer-dependencies/
* - https://stackoverflow.com/questions/46618852/require-and-instanceof/46630766
* Use name property instead.
*/
if (typeof remoteResult === 'boolean' || remoteResult === undefined) {
// nop
}
else if (remoteResult.name === 'InvalidGitRemoteError') {
// checkFetch hardly invoke this error because checkFetch is called just after addRemote.
throw (0, remote_engine_1.wrappingRemoteEngineError)(remoteResult);
}
else if (remoteResult.name === 'InvalidURLFormatError' ||
remoteResult.name === 'InvalidRepositoryURLError' ||
remoteResult.name === 'InvalidSSHKeyPathError' ||
remoteResult.name === 'InvalidAuthenticationTypeError' ||
remoteResult.name === 'HTTPError401AuthorizationRequired' ||
remoteResult.name === 'NetworkError' ||
remoteResult.name === 'CannotConnectError') {
throw (0, remote_engine_1.wrappingRemoteEngineError)(remoteResult);
}
else if (remoteResult.name === 'HTTPError404NotFound') {
// Try to create repository by octokit
// eslint-disable-next-line no-await-in-loop
await this.remoteRepository.create().catch(err => {
throw new error_1.Err.CannotCreateRemoteRepositoryError(err.message);
});
isNewRemoteRepository = true;
}
let syncResult = {
action: 'nop',
};
if (this._options.syncDirection === 'pull') {
// Do not create a new remote repository because the direction is 'pull'.
/**
* TODO: Implement case when sync_direction is 'pull'.
*/
}
else {
// push or both
if (this._options.syncDirection === 'both') {
// Check remote branch after fetching.
const remoteCommitOid = await isomorphic_git_1.default
.resolveRef({
fs: fs_extra_1.default,
dir: this._gitDDB.workingDir,
ref: `refs/remotes/${this.remoteName}/${this._gitDDB.defaultBranch}`,
})
.catch(() => undefined);
if (remoteCommitOid === undefined) {
// Remote repository is empty.
isNewRemoteRepository = true;
}
}
if (isNewRemoteRepository) {
this._gitDDB.logger.debug('upstream branch is not set yet. tryPush..');
// Remote repository may not be created yet due to internal delay of GitHub.
// Retry if not exist.
for (let i = 0; i < this._options.retry + 1; i++) {
// eslint-disable-next-line no-await-in-loop
const syncResultOrError = await this.tryPush().catch(err => err);
if (syncResultOrError instanceof Error) {
if (syncResultOrError instanceof remote_engine_1.RemoteErr.HTTPError404NotFound) {
// eslint-disable-next-line no-await-in-loop
await (0, utils_1.sleep)(this._options.retryInterval);
if (i === this._options.retry) {
throw syncResultOrError;
}
continue;
}
throw syncResultOrError;
}
syncResult = syncResultOrError;
break;
}
}
else if (this._options.syncDirection === 'push') {
this._gitDDB.logger.debug('upstream_branch exists. tryPush..');
syncResult = await this.tryPush();
}
else if (this._options.syncDirection === 'both') {
this._gitDDB.logger.debug('upstream_branch exists. trySync..');
syncResult = await this.trySync();
}
}
const branchRemote = await isomorphic_git_1.default.getConfig({
fs: fs_extra_1.default,
dir: this._gitDDB.workingDir,
path: `branch.${this._gitDDB.defaultBranch}.remote`,
});
if (branchRemote === undefined) {
await isomorphic_git_1.default.setConfig({
fs: fs_extra_1.default,
dir: this._gitDDB.workingDir,
path: `branch.${this._gitDDB.defaultBranch}.remote`,
value: this.remoteName,
});
}
const branchMerge = await isomorphic_git_1.default.getConfig({
fs: fs_extra_1.default,
dir: this._gitDDB.workingDir,
path: `branch.${this._gitDDB.defaultBranch}.merge`,
});
if (branchMerge === undefined) {
await isomorphic_git_1.default.setConfig({
fs: fs_extra_1.default,
dir: this._gitDDB.workingDir,
path: `branch.${this._gitDDB.defaultBranch}.merge`,
value: `refs/heads/${this._gitDDB.defaultBranch}`,
});
}
if (this._options.live) {
if (this._syncTimer === undefined) {
this.eventHandlers.resume.forEach(listener => {
listener.func();
});
this._syncTimer = (0, timers_1.setInterval)(() => {
if (this.runBeforeLiveSync !== undefined) {
try {
this.runBeforeLiveSync();
}
catch (e) {
this._gitDDB.logger.debug('Error in runBeforeLiveSync: ' + e);
}
}
if (this._options.syncDirection === 'push') {
this.tryPushImpl(true).catch(() => undefined);
}
else if (this._options.syncDirection === 'both') {
this.trySyncImpl(true).catch(() => undefined);
}
}, this._options.interval);
}
}
return syncResult;
}
/**
* Pause synchronization
*
* @public
*/
pause() {
if (!this._options.live)
return false;
// Cancel retrying
this._retrySyncCounter = 0;
if (this._syncTimer) {
(0, timers_1.clearInterval)(this._syncTimer);
}
this._options.live = false;
this.eventHandlers.pause.forEach(listener => {
listener.func();
});
return true;
}
/**
* Resume synchronization
*
* @remarks
* Give new settings if needed.
*
* @throws {@link Err.IntervalTooSmallError}
*
* @public
*/
resume(options) {
if (this._isClosed)
return false;
if (this._options.live)
return false;
options !== null && options !== void 0 ? options : (options = {
interval: undefined,
retry: undefined,
});
if (options.interval !== undefined) {
if (options.interval >= const_1.MINIMUM_SYNC_INTERVAL) {
this._options.interval = options.interval;
}
else {
throw new error_1.Err.IntervalTooSmallError(const_1.MINIMUM_SYNC_INTERVAL, options.interval);
}
}
if (options.retry !== undefined) {
this._options.retry = options.retry;
}
this._options.live = true;
this._syncTimer = (0, timers_1.setInterval)(() => {
if (this._options.syncDirection === 'push') {
this.tryPushImpl(true).catch(() => undefined);
}
else if (this._options.syncDirection === 'both') {
this.trySyncImpl(true).catch(() => undefined);
}
}, this._options.interval);
this.eventHandlers.resume.forEach(listener => {
listener.func();
});
return true;
}
/**
* Stop and clear remote connection
*
* @public
*/
close() {
this._isClosed = true;
this.pause();
this.eventHandlers = {
change: [],
localChange: [],
remoteChange: [],
combine: [],
pause: [],
resume: [],
start: [],
complete: [],
error: [],
};
}
/**
* Try to push
*
* @throws {@link Err.PushNotAllowedError}
*
* @throws # Errors from push
* @throws - {@link RemoteErr.InvalidGitRemoteError}
* @throws - {@link RemoteErr.UnfetchedCommitExistsError}
* @throws - {@link RemoteErr.InvalidURLFormatError}
* @throws - {@link RemoteErr.NetworkError}
* @throws - {@link RemoteErr.HTTPError401AuthorizationRequired}
* @throws - {@link RemoteErr.HTTPError404NotFound}
* @throws - {@link RemoteErr.HTTPError403Forbidden}
* @throws - {@link RemoteErr.CannotConnectError}
* @throws - {@link RemoteErr.UnfetchedCommitExistsError}
* @throws - {@link RemoteErr.CannotConnectError}
* @throws - {@link RemoteErr.InvalidURLFormatError}
* @throws - {@link RemoteErr.InvalidRepositoryURLError}
* @throws - {@link RemoteErr.InvalidSSHKeyPathError}
* @throws - {@link RemoteErr.InvalidAuthenticationTypeError}
*
* @throws # Errors from getChanges
* @throws - {@link Err.InvalidJsonObjectError}
*
* @public
*/
async tryPush() {
return await this.tryPushImpl(false);
}
/**
* tryPushImpl
*
* @internal
*/
// eslint-disable-next-line complexity
async tryPushImpl(calledAsPeriodicTask) {
if (this._isClosed)
return { action: 'canceled' };
if (this._options.syncDirection === 'pull') {
throw new error_1.Err.PushNotAllowedError(this._options.syncDirection);
}
/**
* Enqueue pushWorker
*/
const taskId = this._gitDDB.taskQueue.newTaskId();
const callback = (resolve, reject) => (beforeResolve, beforeReject, taskMetadata) => {
if (calledAsPeriodicTask && !this._options.live) {
return Promise.resolve().then(() => {
const resultCancel = {
action: 'canceled',
};
beforeResolve();
resolve(resultCancel);
});
}
return (0, push_worker_1.pushWorker)(this._gitDDB, this, taskMetadata)
.then((syncResultPush) => {
this._gitDDB.logger.debug(`pushWorker: ${JSON.stringify(syncResultPush)}`, utils_1.CONSOLE_STYLE.bgWhite().fgBlack().tag);
if (syncResultPush.action === 'push') {
this.eventHandlers.change.forEach(listener => {
const filteredSyncResultPush = filterChanges(JSON.parse(JSON.stringify(syncResultPush)), listener.collectionPath);
listener.func(filteredSyncResultPush, {
...taskMetadata,
collectionPath: listener.collectionPath,
});
});
this.eventHandlers.remoteChange.forEach(listener => {
const filteredSyncResultPush = filterChanges(JSON.parse(JSON.stringify(syncResultPush)), listener.collectionPath);
listener.func(filteredSyncResultPush.changes.remote, {
...taskMetadata,
collectionPath: listener.collectionPath,
});
});
}
if (syncResultPush.action === 'push') {
this.eventHandlers.complete.forEach(listener => {
listener.func({ ...taskMetadata, collectionPath: listener.collectionPath });
});
}
beforeResolve();
resolve(syncResultPush);
})
.catch(err => {
// console.log(`Error in push_worker: ${err}`);
this.eventHandlers.error.forEach(listener => {
listener.func(err, {
...taskMetadata,
collectionPath: listener.collectionPath,
});
});
beforeReject();
reject(err);
});
};
const cancel = (resolve) => () => {
const result = { action: 'canceled' };
this._gitDDB.logger.debug(`pushWorker: ${JSON.stringify(result)}`, utils_1.CONSOLE_STYLE.bgWhite().fgBlack().tag);
resolve(result);
};
const task = (resolve, reject) => {
return {
label: 'push',
taskId: taskId,
syncRemoteName: this.remoteName,
func: callback(resolve, reject),
cancel: cancel(resolve),
};
};
const resultOrError = await new Promise((resolve, reject) => {
this._gitDDB.taskQueue.pushToTaskQueue(task(resolve, reject));
// this._gitDDB.taskQueue.unshiftSyncTaskToTaskQueue(task(resolve, reject));
}).catch((err) => err);
if (resultOrError instanceof Error) {
if (resultOrError instanceof remote_engine_1.RemoteErr.UnfetchedCommitExistsError) {
if (this._options.syncDirection === 'push') {
if (this._options.combineDbStrategy === 'replace-with-ours') {
// TODO: Exec replace-with-ours instead of throw error
}
}
}
// Fatal error. Don't retry?
// this.pause();
throw resultOrError;
}
return resultOrError;
}
/**
* Try to sync with retries
*
* @throws {@link Err.PushNotAllowedError}
* @throws {@link Err.CombineDatabaseError}
*
* @throws # Errors from syncWorker
* @throws - {@link Err.NoMergeBaseFoundError}
* @throws - {@link Err.ThreeWayMergeError}
* @throws - {@link Err.CannotDeleteDataError}
*
* @throws # Errors from fetch, pushWorker
* @throws - {@link RemoteErr.InvalidGitRemoteError}
* @throws - {@link RemoteErr.InvalidURLFormatError}
* @throws - {@link RemoteErr.NetworkError}
* @throws - {@link RemoteErr.HTTPError401AuthorizationRequired}
* @throws - {@link RemoteErr.HTTPError404NotFound}
* @throws - {@link RemoteErr.CannotConnectError}
* @throws - {@link RemoteErr.InvalidURLFormatError}
* @throws - {@link RemoteErr.InvalidRepositoryURLError}
* @throws - {@link RemoteErr.InvalidSSHKeyPathError}
* @throws - {@link RemoteErr.InvalidAuthenticationTypeError}
*
* @throws # Errors from pushWorker
* @throws - {@link RemoteErr.HTTPError403Forbidden}
* @throws - {@link RemoteErr.UnfetchedCommitExistsError}
*
* @throws # Errors from merge
* @throws - {@link Err.InvalidConflictStateError}
* @throws - {@link Err.CannotDeleteDataError}
* @throws - {@link Err.InvalidDocTypeError}
* @throws - {@link Err.InvalidConflictResolutionStrategyError}
* @throws - {@link Err.CannotCreateDirectoryError}
* @throws - {@link Err.InvalidJsonObjectError}
*
* @throws # Errors from getChanges
* @throws - {@link Err.InvalidJsonObjectError}
*
* @public
*/
async trySync() {
return await this.trySyncImpl(false);
}
/**
* trySyncImpl
*
* @internal
*/
// eslint-disable-next-line complexity
async trySyncImpl(calledAsPeriodicTask) {
if (this._isClosed)
return { action: 'canceled' };
if (this._options.syncDirection === 'pull') {
throw new error_1.Err.PushNotAllowedError(this._options.syncDirection);
}
if (this._retrySyncCounter === 0) {
this._retrySyncCounter = this._options.retry + 1;
}
while (this._retrySyncCounter > 0) {
// eslint-disable-next-line no-await-in-loop
const resultOrError = await this.enqueueSyncTask(calledAsPeriodicTask).catch((err) => err);
let error;
let result;
if (resultOrError instanceof Error) {
error = resultOrError;
}
else if (resultOrError.action === 'merge and push error' ||
resultOrError.action === 'resolve conflicts and push error') {
result = resultOrError;
error = resultOrError.error;
}
else {
result = resultOrError;
}
if (error instanceof error_1.Err.NoMergeBaseFoundError) {
if (this._options.combineDbStrategy === 'throw-error') {
// nop
}
else if (this._options.combineDbStrategy === 'combine-head-with-theirs') {
// return SyncResultCombineDatabase
// eslint-disable-next-line no-await-in-loop
const syncResultCombineDatabase = await (0, combine_1.combineDatabaseWithTheirs)(this._gitDDB, this._options, this.remoteName).catch(err => {
if (err)
// throw new Err.CombineDatabaseError(err.message);
error = new error_1.Err.CombineDatabaseError(err.message);
return undefined;
});
if (syncResultCombineDatabase !== undefined) {
// eslint-disable-next-line no-loop-func
this.eventHandlers.combine.forEach(callback => callback.func(syncResultCombineDatabase.duplicates));
return syncResultCombineDatabase;
}
}
}
if (error !== undefined) {
this._gitDDB.logger.debug('trySync failed: ' + error.message);
if (error instanceof remote_engine_1.RemoteErr.UnfetchedCommitExistsError) {
this._retrySyncCounter--;
if (this._retrySyncCounter === 0) {
// this.pause();
throw error;
}
this._gitDDB.logger.debug(`...retrySync: ${this.currentRetries().toString()}`, utils_1.CONSOLE_STYLE.bgRed().tag);
// eslint-disable-next-line no-await-in-loop
await (0, utils_1.sleep)(this._options.retryInterval);
}
else {
// Throw error
this._retrySyncCounter = 0;
// this.pause();
throw error;
}
}
else if (result !== undefined) {
// No error
this._retrySyncCounter = 0;
return result;
}
}
// This line is reached when cancel() set _retrySyncCounter to 0;
const cancel = { action: 'canceled' };
this._gitDDB.logger.debug(`syncWorker: ${JSON.stringify(cancel)}`, utils_1.CONSOLE_STYLE.bgWhite().fgBlack().tag);
this._retrySyncCounter = 0;
return cancel;
}
/**
* Enqueue sync task to TaskQueue
*
* @public
*/
enqueueSyncTask(calledAsPeriodicTask) {
const taskId = this._gitDDB.taskQueue.newTaskId();
const callback = (resolve, reject) => (beforeResolve, beforeReject, taskMetadata) => {
if (calledAsPeriodicTask && !this._options.live) {
return Promise.resolve().then(() => {
const resultCancel = {
action: 'canceled',
};
beforeResolve();
resolve(resultCancel);
});
}
return ((0, sync_worker_1.syncWorker)(this._gitDDB, this, taskMetadata)
// eslint-disable-next-line complexity
.then((syncResult) => {
this._gitDDB.logger.debug(`syncWorker: ${JSON.stringify(syncResult)}`, utils_1.CONSOLE_STYLE.bgWhite().fgBlack().tag);
if (syncResult.action === 'resolve conflicts and push' ||
syncResult.action === 'merge and push' ||
syncResult.action === 'resolve conflicts and push error' ||
syncResult.action === 'merge and push error' ||
syncResult.action === 'fast-forward merge' ||
syncResult.action === 'push') {
this.eventHandlers.change.forEach(listener => {
let syncResultForChangeEvent = JSON.parse(JSON.stringify(syncResult));
syncResultForChangeEvent = filterChanges(syncResultForChangeEvent, listener.collectionPath);
listener.func(syncResultForChangeEvent, {
...taskMetadata,
collectionPath: listener.collectionPath,
});
});
}
if (syncResult.action === 'resolve conflicts and push' ||
syncResult.action === 'merge and push' ||
syncResult.action === 'resolve conflicts and push error' ||
syncResult.action === 'merge and push error' ||
syncResult.action === 'fast-forward merge') {
this.eventHandlers.localChange.forEach(listener => {
let syncResultForLocalChangeEvent = JSON.parse(JSON.stringify(syncResult));
syncResultForLocalChangeEvent = filterChanges(syncResultForLocalChangeEvent, listener.collectionPath);
listener.func(syncResultForLocalChangeEvent.changes.local, {
...taskMetadata,
collectionPath: listener.collectionPath,
});
});
}
if (syncResult.action === 'resolve conflicts and push' ||
syncResult.action === 'merge and push' ||
syncResult.action === 'push') {
this.eventHandlers.remoteChange.forEach(listener => {
let syncResultForRemoteChangeEvent = JSON.parse(JSON.stringify(syncResult));
syncResultForRemoteChangeEvent = filterChanges(syncResultForRemoteChangeEvent, listener.collectionPath);
listener.func(syncResultForRemoteChangeEvent.changes.remote, {
...taskMetadata,
collectionPath: listener.collectionPath,
});
});
}
this.eventHandlers.complete.forEach(listener => listener.func({
...taskMetadata,
collectionPath: listener.collectionPath,
}));
beforeResolve();
resolve(syncResult);
})
.catch((err) => {
// console.log(`Error in syncWorker: ${err}`);
this.eventHandlers.error.forEach(listener => {
listener.func(err, {
...taskMetadata,
collectionPath: listener.collectionPath,
});
});
beforeReject();
reject(err);
}));
};
const cancel = (resolve) => () => {
const result = { action: 'canceled' };
this._gitDDB.logger.debug(`syncWorker: ${JSON.stringify(result)}`, utils_1.CONSOLE_STYLE.bgWhite().fgBlack().tag);
resolve(result);
};
const task = (resolve, reject) => {
return {
label: 'sync',
taskId: taskId,
syncRemoteName: this.remoteName,
func: callback(resolve, reject),
cancel: cancel(resolve),
};
};
return new Promise((resolve, reject) => {
this._gitDDB.taskQueue.pushToTaskQueue(task(resolve, reject));
});
}
/**
* Return current retry count (incremental)
*
* @public
*/
currentRetries() {
let retries = this._options.retry - this._retrySyncCounter + 1;
if (this._retrySyncCounter === 0)
retries = 0;
return retries;
}
/**
* Add SyncEvent handler
*
* @eventProperty
* @public
*/
// eslint-disable-next-line complexity
on(event, callback, collectionPath = '') {
if (this._isClosed)
return this;
collectionPath = validator_1.Validator.normalizeCollectionPath(collectionPath);
if (event === 'change')
this.eventHandlers[event].push({
collectionPath,
func: callback,
});
if (event === 'localChange')
this.eventHandlers[event].push({
collectionPath,
func: callback,
});
if (event === 'remoteChange')
this.eventHandlers[event].push({
collectionPath,
func: callback,
});
if (event === 'combine')
this.eventHandlers[event].push({
collectionPath,
func: callback,
});
if (event === 'pause')
this.eventHandlers[event].push({
collectionPath,
func: callback,
});
if (event === 'resume')
this.eventHandlers[event].push({
collectionPath,
func: callback,
});
if (event === 'start')
this.eventHandlers[event].push({
collectionPath,
func: callback,
});
if (event === 'complete')
this.eventHandlers[event].push({
collectionPath,
func: callback,
});
if (event === 'error')
this.eventHandlers[event].push({
collectionPath,
func: callback,
});
return this;
}
/**
* Remove SyncEvent handler
*
* @eventProperty
* @public
*/
off(event, callback) {
if (this._isClosed)
return this;
// @ts-ignore
this.eventHandlers[event] = this.eventHandlers[event].filter((listener) => {
return listener.func !== callback;
});
return this;
}
}
exports.Sync = Sync;
//# sourceMappingURL=sync.js.map