kura-xmitter
Version:
The FileSystem API abstraction library, file transmitter
834 lines (775 loc) • 25 kB
text/typescript
import {
AbstractAccessor,
deepCopy,
DIR_SEPARATOR,
FileNameIndex,
FileNameIndexEntry,
FileSystemAsync,
FileSystemObject,
NotFoundError,
Transferer,
} from "kura";
import { SyncOptions } from "./SyncOptions";
export interface SyncResult {
backward: boolean;
errors: any[];
forward: boolean;
}
export interface Handler {
afterCopy: (
fromAccessor: AbstractAccessor,
toAccessor: AbstractAccessor,
obj: FileSystemObject
) => Promise<void>;
afterDelete: (
accessor: AbstractAccessor,
obj: FileSystemObject
) => Promise<void>;
beforeCopy: (
fromAccessor: AbstractAccessor,
toAccessor: AbstractAccessor,
obj: FileSystemObject
) => Promise<boolean>;
beforeDelete: (
accessor: AbstractAccessor,
obj: FileSystemObject
) => Promise<boolean>;
completed: (result: SyncResult, error?: any) => Promise<void>;
getNames: (fileNameIndex: FileNameIndex) => string[];
}
const DEFAULT_HANDLER: Handler = {
afterCopy: async () => {
/* noop */
},
afterDelete: async () => {
/* noop */
},
beforeCopy: () => Promise.resolve(false),
beforeDelete: () => Promise.resolve(false),
completed: async (result) => {
/* noop */
},
getNames: (fileNameIndex) => {
return Object.values(fileNameIndex)
.sort((a, b) => b.modified - a.modified)
.map((record) => record.name);
},
};
export class Notifier {
private _processed = 0;
private _total = 0;
constructor(
private _callback = (processed: number, total: number) => {
/* noop */
}
) {}
public get processed() {
return this._processed;
}
public get total() {
return this._total;
}
public incrementProcessed(count = 1) {
this._processed = this._processed + count;
this._callback(this._processed, this._total);
}
public incrementTotal(count = 1) {
this._total = this._total + count;
this._callback(this._processed, this._total);
}
}
const DEFAULT_NOTIFIER = new Notifier();
export const SYNC_RESULT_FALSES: SyncResult = {
forward: false,
backward: false,
errors: [],
};
export class Synchronizer {
private static NOT_EXISTS = 0;
private excludeNameRegExp: RegExp;
private excludePathRegExp: RegExp;
private localAccessor: AbstractAccessor;
private remoteAccessor: AbstractAccessor;
private transferer: Transferer;
constructor(
public local: FileSystemAsync,
public remote: FileSystemAsync,
private options: SyncOptions = {}
) {
if (options.excludeNamePattern == null) {
options.excludeNamePattern = "^\\.|^$";
}
if (options.excludePathPattern == null) {
options.excludePathPattern = "\\/\\.";
}
if (options.verbose == null) options.verbose = false;
this.excludeNameRegExp = new RegExp(options.excludeNamePattern);
this.excludePathRegExp = new RegExp(options.excludePathPattern);
const localFS = local.filesystem;
this.localAccessor = localFS.accessor;
if (!this.localAccessor || !this.localAccessor.options.index) {
throw new Error(`Source filesystem "${localFS.name}" has no index`);
}
const remoteFS = remote.filesystem;
this.remoteAccessor = remoteFS.accessor;
if (!this.remoteAccessor || !this.remoteAccessor.options.index) {
throw new Error(`Destination filesystem "${remoteFS.name}" has no index`);
}
if (options.transferer) {
this.transferer = options.transferer;
} else {
this.transferer = new Transferer();
}
}
public async synchronizeAll() {
return await this.synchronizeDirectory(this.local.root.fullPath, true);
}
public async synchronizeDirectory(
dirPath: string,
recursively: boolean,
notifier = DEFAULT_NOTIFIER,
handler = DEFAULT_HANDLER
): Promise<SyncResult> {
try {
if (!dirPath) {
dirPath = DIR_SEPARATOR;
}
const result = await this.synchronizeChildren(
this.localAccessor,
this.remoteAccessor,
dirPath,
recursively,
notifier,
handler
);
this.debug(
this.localAccessor,
this.remoteAccessor,
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`SyncResult: localToRemote=${result.forward}, remoteToLocal=${result.backward}`,
dirPath
);
await handler.completed(result);
return result;
} catch (e) {
console.warn("synchronizeDirectory", { dirPath, recursively }, e);
const result: SyncResult = { ...SYNC_RESULT_FALSES, errors: [e] };
await handler.completed(result, e);
return result;
}
}
private async copyFile(
fromAccessor: AbstractAccessor,
toAccessor: AbstractAccessor,
fromRecord: FileNameIndexEntry,
handler: Handler
) {
const obj = this.toFileSystemObject(fromRecord);
if (await handler.beforeCopy(fromAccessor, toAccessor, obj)) {
return;
}
toAccessor.clearContentsCache(obj.fullPath);
await this.transferer.transfer(fromAccessor, obj, toAccessor, obj);
await toAccessor.saveRecord(obj.fullPath, {
modified: fromRecord.modified,
size: fromRecord.size,
});
await handler.afterCopy(fromAccessor, toAccessor, obj);
this.debug(fromAccessor, toAccessor, "copyFile", obj.fullPath);
}
private debug(
fromAccessor: AbstractAccessor,
toAccessor: AbstractAccessor,
title: string,
path: string
) {
if (!this.options.verbose) {
return;
}
if (fromAccessor) {
console.debug(
`${fromAccessor.name} => ${toAccessor.name} - ${title}: ${path}`
);
} else {
console.debug(`${toAccessor.name} - ${title}: ${path}`);
}
}
private async deleteEntry(
accessor: AbstractAccessor,
record: FileNameIndexEntry,
handler: Handler
) {
const obj = this.toFileSystemObject(record);
const fullPath = obj.fullPath;
const isFile = obj.size != null;
this.debug(null, accessor, "delete", fullPath);
try {
if (await handler.beforeDelete(accessor, obj)) {
return;
}
if (isFile) {
await accessor.delete(fullPath, true, false);
} else {
await accessor.deleteRecursively(fullPath, false);
}
await handler.afterDelete(accessor, obj);
} catch (e) {
if (e instanceof NotFoundError) {
console.info(e, fullPath, "deleteEntry");
} else {
console.warn(e, fullPath, "deleteEntry");
}
}
}
private async makeDirectory(
accessor: AbstractAccessor,
record: FileNameIndexEntry
) {
const obj = this.toFileSystemObject(record);
await accessor.doPutObject(obj);
await accessor.saveRecord(obj.fullPath, {
modified: record.modified ?? Date.now(),
});
}
private mergeResult(result: SyncResult, merged: SyncResult) {
merged.forward = merged.forward || result.forward;
merged.backward = merged.backward || result.backward;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
merged.errors = [...merged.errors, ...result.errors];
}
private async synchronizeChildren(
fromAccessor: AbstractAccessor,
toAccessor: AbstractAccessor,
dirPath: string,
recursively: boolean,
notifier: Notifier,
handler: Handler
): Promise<SyncResult> {
if (this.excludePathRegExp.test(dirPath)) {
return SYNC_RESULT_FALSES;
}
const fromFileNameIndex = await fromAccessor.getFileNameIndex(dirPath);
const toFileNameIndex = await toAccessor.getFileNameIndex(dirPath);
const fromNames = handler.getNames(fromFileNameIndex);
notifier.incrementTotal(fromNames.length);
const toNames = handler.getNames(toFileNameIndex);
const fromToResult = deepCopy(SYNC_RESULT_FALSES);
const toFromResult = deepCopy(SYNC_RESULT_FALSES);
outer: for (const fromName of fromNames) {
if (this.excludeNameRegExp.test(fromName)) {
notifier.incrementProcessed();
continue;
}
for (let i = 0, end = toNames.length; i < end; i++) {
const toName = toNames[i];
if (fromName !== toName) {
continue;
}
const oneResult = await this.synchronizeOne(
fromAccessor,
fromFileNameIndex,
toAccessor,
toFileNameIndex,
fromName,
recursively,
notifier,
handler
);
this.mergeResult(oneResult, fromToResult);
notifier.incrementProcessed();
toNames.splice(i, 1);
continue outer;
}
// destination not found.
const oneResult = await this.synchronizeOne(
fromAccessor,
fromFileNameIndex,
toAccessor,
toFileNameIndex,
fromName,
recursively,
notifier,
handler
);
this.mergeResult(oneResult, fromToResult);
notifier.incrementProcessed();
}
// source not found
notifier.incrementTotal(toNames.length);
for (const toName of toNames) {
if (this.excludeNameRegExp.test(toName)) {
notifier.incrementProcessed();
continue;
}
const oneResult = await this.synchronizeOne(
toAccessor,
toFileNameIndex,
fromAccessor,
fromFileNameIndex,
toName,
recursively,
notifier,
handler
);
this.mergeResult(oneResult, toFromResult);
notifier.incrementProcessed();
}
const result: SyncResult = {
forward: fromToResult.forward || toFromResult.backward,
backward: fromToResult.backward || toFromResult.forward,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
errors: [...fromToResult.errors, ...toFromResult.errors],
};
return result;
}
private async synchronizeOne(
fromAccessor: AbstractAccessor,
fromFileNameIndex: FileNameIndex,
toAccessor: AbstractAccessor,
toFileNameIndex: FileNameIndex,
name: string,
recursively: boolean,
notifier: Notifier,
handler: Handler
): Promise<SyncResult> {
const result = deepCopy(SYNC_RESULT_FALSES);
try {
let fromRecord = fromFileNameIndex[name];
let toRecord = toFileNameIndex[name];
if (fromRecord == null && toRecord == null) {
this.warn(fromAccessor, toAccessor, name, "No records");
return result;
}
if (fromRecord != null && toRecord == null) {
toRecord = deepCopy(fromRecord);
delete toRecord.deleted;
toRecord.modified = Synchronizer.NOT_EXISTS;
} else if (fromRecord == null && toRecord != null) {
fromRecord = deepCopy(toRecord);
delete fromRecord.deleted;
fromRecord.modified = Synchronizer.NOT_EXISTS;
}
const fullPath = fromRecord.fullPath;
const fromModified = fromRecord.modified;
const toModified = toRecord.modified;
if (fromRecord.size == null && toRecord.size != null) {
this.warn(
fromAccessor,
toAccessor,
fullPath,
"source is directory and destination is file"
);
return result;
} else if (fromRecord.size != null && toRecord.size == null) {
this.warn(
fromAccessor,
toAccessor,
fullPath,
"source is file and destination is directory"
);
return result;
}
const fromDeleted = fromRecord.deleted;
const toDeleted = toRecord.deleted;
if (fromDeleted != null && toDeleted != null) {
this.debug(
fromAccessor,
toAccessor,
(fromRecord.size != null ? "file" : "dir") + "[-from,-to]",
fullPath
);
return result;
}
if (fromRecord.size != null) {
// file
if (fromDeleted != null && toDeleted == null) {
if (fromDeleted < toModified) {
try {
await this.copyFile(toAccessor, fromAccessor, toRecord, handler);
result.backward = true;
this.debug(
fromAccessor,
toAccessor,
"file[-from,to (-from < to) => +from]",
fullPath
);
} catch (e) {
if (e instanceof NotFoundError) {
await toAccessor.deleteRecord(fullPath, true);
this.debug(
fromAccessor,
toAccessor,
"file[-from,to (-to!) => -to]",
fullPath
);
} else {
throw e;
}
}
} else {
if (toModified !== Synchronizer.NOT_EXISTS) {
await this.deleteEntry(toAccessor, toRecord, handler);
result.forward = true;
this.debug(
fromAccessor,
toAccessor,
"file[-from,to (to <= -from) => -to]",
fullPath
);
} else {
if (fromAccessor === this.localAccessor) {
// delete local index
await this.truncateRecord(toAccessor, fullPath);
}
this.debug(
fromAccessor,
toAccessor,
"file[-from,?to =>]",
fullPath
);
}
}
} else if (fromDeleted == null && toDeleted != null) {
if (toDeleted < fromModified) {
try {
await this.copyFile(
fromAccessor,
toAccessor,
fromRecord,
handler
);
result.forward = true;
this.debug(
fromAccessor,
toAccessor,
"file[from,-to (-to < from) => +to]",
fullPath
);
} catch (e) {
if (e instanceof NotFoundError) {
await fromAccessor.deleteRecord(fullPath, true);
this.debug(
fromAccessor,
toAccessor,
"file[from,-to (-from!) => -from]",
fullPath
);
} else {
throw e;
}
}
} else {
if (fromModified !== Synchronizer.NOT_EXISTS) {
await this.deleteEntry(fromAccessor, fromRecord, handler);
result.backward = true;
this.debug(
toAccessor,
fromAccessor,
"file[from,-to (from < -to) => -from]",
fullPath
);
} else {
if (toAccessor === this.localAccessor) {
// delete local index
await this.truncateRecord(toAccessor, fullPath);
}
this.debug(
toAccessor,
fromAccessor,
"file[?from,-to =>]",
fullPath
);
}
}
} else if (fromDeleted == null && toDeleted == null) {
if (toModified < fromModified) {
try {
await this.copyFile(
fromAccessor,
toAccessor,
fromRecord,
handler
);
result.forward = true;
this.debug(
fromAccessor,
toAccessor,
"file[from,to (to < from) => +to]",
fullPath
);
} catch (e) {
if (e instanceof NotFoundError) {
try {
await this.copyFile(
toAccessor,
fromAccessor,
toRecord,
handler
);
result.backward = true;
this.debug(
fromAccessor,
toAccessor,
"file[from,to (!-from) => +from]",
fullPath
);
} catch (e2) {
if (e2 instanceof NotFoundError) {
await this.deleteEntry(fromAccessor, fromRecord, handler);
await this.deleteEntry(toAccessor, toRecord, handler);
result.forward = true;
result.backward = true;
this.debug(
fromAccessor,
toAccessor,
"file[from,to (!-from,!-to) => -from,-to]",
fullPath
);
} else {
throw e2;
}
}
} else {
throw e;
}
}
} else if (fromModified < toModified) {
try {
await this.copyFile(toAccessor, fromAccessor, toRecord, handler);
result.backward = true;
this.debug(
fromAccessor,
toAccessor,
"file[from,to (from < to) => +from]",
fullPath
);
} catch (e) {
if (e instanceof NotFoundError) {
try {
await this.copyFile(
fromAccessor,
toAccessor,
toRecord,
handler
);
result.forward = true;
this.debug(
fromAccessor,
toAccessor,
"file[from,to (!-to) => +to]",
fullPath
);
} catch (e2) {
if (e2 instanceof NotFoundError) {
await this.deleteEntry(fromAccessor, fromRecord, handler);
await this.deleteEntry(toAccessor, toRecord, handler);
result.forward = true;
result.backward = true;
this.debug(
fromAccessor,
toAccessor,
"file[from,to (!-from,!-to) => -from,-to]",
fullPath
);
} else {
throw e2;
}
}
} else {
throw e;
}
}
} else {
this.debug(
fromAccessor,
toAccessor,
"file[from,to (from == to) =>]",
fullPath
);
}
}
} else {
// directory
if (fromDeleted != null && toDeleted == null) {
if (fromDeleted < toModified) {
await this.makeDirectory(fromAccessor, toRecord);
await this.synchronizeChildren(
toAccessor,
fromAccessor,
fullPath,
true,
notifier,
handler
);
result.backward = true;
this.debug(
fromAccessor,
toAccessor,
"dir[-from,to (-from < to) => +from]",
fullPath
);
} else {
if (toModified !== Synchronizer.NOT_EXISTS) {
await this.deleteEntry(toAccessor, toRecord, handler);
result.forward = true;
this.debug(
fromAccessor,
toAccessor,
"dir[-from,to (to < -from) => -to]",
fullPath
);
} else {
if (fromAccessor === this.localAccessor) {
// delete local index
await this.truncateRecord(toAccessor, fullPath);
}
this.debug(
fromAccessor,
toAccessor,
"dir[-from,?to =>]",
fullPath
);
}
}
} else if (fromDeleted == null && toDeleted != null) {
if (toDeleted < fromModified) {
await this.makeDirectory(toAccessor, fromRecord);
await this.synchronizeChildren(
fromAccessor,
toAccessor,
fullPath,
true,
notifier,
handler
);
result.forward = true;
this.debug(
fromAccessor,
toAccessor,
"dir[from,-to (-to < from) => +to]",
fullPath
);
} else {
if (fromModified !== Synchronizer.NOT_EXISTS) {
await this.deleteEntry(fromAccessor, fromRecord, handler);
result.backward = true;
this.debug(
toAccessor,
fromAccessor,
"dir[from,-to (from < -to) => -from]",
fullPath
);
} else {
if (toAccessor === this.localAccessor) {
// delete local index
await this.truncateRecord(toAccessor, fullPath);
}
this.debug(
toAccessor,
fromAccessor,
"dir[?from,-to =>]",
fullPath
);
}
}
} else if (fromDeleted == null && toDeleted == null) {
// prioritize older
if (fromModified === Synchronizer.NOT_EXISTS) {
await this.makeDirectory(fromAccessor, toRecord);
result.backward = true;
this.debug(
fromAccessor,
toAccessor,
"dir[?from,to => +from]",
fullPath
);
} else if (toModified === Synchronizer.NOT_EXISTS) {
await this.makeDirectory(toAccessor, fromRecord);
result.forward = true;
this.debug(
fromAccessor,
toAccessor,
"dir[from,?to => +to]",
fullPath
);
} else if (toModified < fromModified) {
await fromAccessor.saveRecord(fullPath, toRecord);
result.backward = true;
this.debug(
fromAccessor,
toAccessor,
"dir[from,to (to < from) => +from]",
fullPath
);
} else if (fromModified < toModified) {
await toAccessor.saveRecord(fullPath, fromRecord);
result.forward = true;
this.debug(
fromAccessor,
toAccessor,
"dir[from,to (from < to) => +to]",
fullPath
);
} else {
this.debug(
fromAccessor,
toAccessor,
"dir[from,to (from == to) =>]",
fullPath
);
}
if (recursively) {
await this.synchronizeChildren(
fromAccessor,
toAccessor,
fullPath,
recursively,
notifier,
handler
);
}
}
}
} catch (e) {
result.errors.push(e);
this.warn(fromAccessor, toAccessor, name, e);
}
return result;
}
private toFileSystemObject(record: FileNameIndexEntry) {
const obj: FileSystemObject = {
fullPath: record.fullPath,
name: record.name,
lastModified: record.modified,
size: record.size,
};
return obj;
}
private async truncateRecord(accessor: AbstractAccessor, fullPath: string) {
const indexPath = await accessor.createIndexPath(fullPath, false);
try {
await accessor.doGetObject(fullPath, true);
await accessor.doDelete(indexPath, true);
} catch (e) {
if (e instanceof NotFoundError) {
console.info(e, fullPath, "truncateRecord");
} else {
console.warn(e, fullPath, "truncateRecord");
}
}
}
private warn(
fromAccessor: AbstractAccessor,
toAccessor: AbstractAccessor,
path: string,
e: any
) {
if (!this.options.verbose) {
return;
}
const message = e ? JSON.stringify(e) : "";
console.warn(
`${fromAccessor.name} => ${toAccessor.name}: ${path}\n` + message
);
}
}