asch/fix-everything #188
48 changed files with 1024 additions and 888 deletions
Simplify syncing logic
commit
4493365076
|
|
@ -14,19 +14,17 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"byte-base64": "^1.1.0",
|
"byte-base64": "^1.1.0",
|
||||||
"minimatch": "^10.0.1",
|
"minimatch": "^10.1.1",
|
||||||
"p-queue": "^8.1.0",
|
"p-queue": "^9.0.1",
|
||||||
"reconcile-text": "^0.8.0",
|
"reconcile-text": "^0.8.0",
|
||||||
"uuid": "^13.0.0",
|
"@types/node": "^25.0.2",
|
||||||
"@types/node": "^24.8.1",
|
"ts-loader": "^9.5.4",
|
||||||
"ts-loader": "^9.5.2",
|
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.9.3",
|
||||||
"webpack": "^5.99.9",
|
"webpack": "^5.103.0",
|
||||||
"webpack-cli": "^6.0.1",
|
"webpack-cli": "^6.0.1",
|
||||||
"webpack-merge": "^6.0.1",
|
"webpack-merge": "^6.0.1",
|
||||||
"@sentry/browser": "^10.8.0",
|
"@sentry/browser": "^10.30.0"
|
||||||
"ws": "^8.18.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,6 @@ export const TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS = 60;
|
||||||
export const DIFF_CACHE_SIZE_MB = 2;
|
export const DIFF_CACHE_SIZE_MB = 2;
|
||||||
export const MAX_LOG_MESSAGE_COUNT = 100000;
|
export const MAX_LOG_MESSAGE_COUNT = 100000;
|
||||||
export const MAX_HISTORY_ENTRY_COUNT = 5000;
|
export const MAX_HISTORY_ENTRY_COUNT = 5000;
|
||||||
export const SUPPORTED_API_VERSION = 2;
|
export const SUPPORTED_API_VERSION = 3;
|
||||||
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_S = 10;
|
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS = 10;
|
||||||
|
export const WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS = 10;
|
||||||
|
|
|
||||||
6
frontend/sync-client/src/errors/authentication-error.ts
Normal file
6
frontend/sync-client/src/errors/authentication-error.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export class AuthenticationError extends Error {
|
||||||
|
public constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AuthenticationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
9
frontend/sync-client/src/errors/file-not-found-error.ts
Normal file
9
frontend/sync-client/src/errors/file-not-found-error.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export class FileNotFoundError extends Error {
|
||||||
|
public constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly filePath: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "FileNotFoundError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export class ServerVersionMismatchError extends Error {
|
||||||
|
public constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ServerVersionMismatchError";
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/sync-client/src/errors/sync-reset-error.ts
Normal file
6
frontend/sync-client/src/errors/sync-reset-error.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export class SyncResetError extends Error {
|
||||||
|
public constructor() {
|
||||||
|
super("SyncClient has been reset, cleaning up");
|
||||||
|
this.name = "SyncResetError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
||||||
|
|
||||||
class MockDatabase implements Partial<Database> {
|
class MockDatabase implements Partial<Database> {
|
||||||
public getLatestDocumentByRelativePath(
|
public getLatestDocumentByRelativePath(
|
||||||
_find: RelativePath
|
_target: RelativePath
|
||||||
): DocumentRecord | undefined {
|
): DocumentRecord | undefined {
|
||||||
// no-op
|
// no-op
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -169,9 +169,9 @@ export class FileOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.ensureClearPath(newPath);
|
await this.ensureClearPath(newPath);
|
||||||
|
|
||||||
this.database.move(oldPath, newPath);
|
this.database.move(oldPath, newPath);
|
||||||
await this.fs.rename(oldPath, newPath);
|
await this.fs.rename(oldPath, newPath);
|
||||||
|
|
||||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { RelativePath } from "../persistence/database";
|
||||||
import type { FileSystemOperations } from "./filesystem-operations";
|
import type { FileSystemOperations } from "./filesystem-operations";
|
||||||
import type { Logger } from "../tracing/logger";
|
import type { Logger } from "../tracing/logger";
|
||||||
import { Locks } from "../utils/data-structures/locks";
|
import { Locks } from "../utils/data-structures/locks";
|
||||||
import { FileNotFoundError } from "./file-not-found-error";
|
import { FileNotFoundError } from "../errors/file-not-found-error";
|
||||||
import type { TextWithCursors } from "reconcile-text";
|
import type { TextWithCursors } from "reconcile-text";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -17,7 +17,7 @@ export class SafeFileSystemOperations implements FileSystemOperations {
|
||||||
private readonly fs: FileSystemOperations,
|
private readonly fs: FileSystemOperations,
|
||||||
private readonly logger: Logger
|
private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
this.locks = new Locks(logger);
|
this.locks = new Locks(SafeFileSystemOperations.name, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { awaitAll } from "./utils/await-all";
|
||||||
import { logToConsole } from "./utils/debugging/log-to-console";
|
import { logToConsole } from "./utils/debugging/log-to-console";
|
||||||
import { slowFetchFactory } from "./utils/debugging/slow-fetch-factory";
|
import { slowFetchFactory } from "./utils/debugging/slow-fetch-factory";
|
||||||
import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory";
|
import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory";
|
||||||
|
import { InMemoryFileSystem } from "./utils/debugging/in-memory-file-system";
|
||||||
import { getRandomColor } from "./utils/get-random-color";
|
import { getRandomColor } from "./utils/get-random-color";
|
||||||
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
|
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
|
||||||
import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
|
import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
|
||||||
|
|
@ -27,8 +28,8 @@ export type { PersistenceProvider } from "./persistence/persistence";
|
||||||
export type { CursorSpan } from "./services/types/CursorSpan";
|
export type { CursorSpan } from "./services/types/CursorSpan";
|
||||||
export type { ClientCursors } from "./services/types/ClientCursors";
|
export type { ClientCursors } from "./services/types/ClientCursors";
|
||||||
export type { NetworkConnectionStatus } from "./types/network-connection-status";
|
export type { NetworkConnectionStatus } from "./types/network-connection-status";
|
||||||
export type { ServerVersionMismatchError } from "./services/server-version-mismatch-error";
|
export type { ServerVersionMismatchError } from "./errors/server-version-mismatch-error";
|
||||||
export type { AuthenticationError } from "./services/authentication-error";
|
export type { AuthenticationError } from "./errors/authentication-error";
|
||||||
export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
|
export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
|
||||||
export { DocumentSyncStatus } from "./types/document-sync-status";
|
export { DocumentSyncStatus } from "./types/document-sync-status";
|
||||||
export { SyncClient } from "./sync-client";
|
export { SyncClient } from "./sync-client";
|
||||||
|
|
@ -37,7 +38,8 @@ export type { TextWithCursors, CursorPosition } from "reconcile-text";
|
||||||
export const debugging = {
|
export const debugging = {
|
||||||
slowFetchFactory,
|
slowFetchFactory,
|
||||||
slowWebSocketFactory,
|
slowWebSocketFactory,
|
||||||
logToConsole
|
logToConsole,
|
||||||
|
InMemoryFileSystem
|
||||||
};
|
};
|
||||||
|
|
||||||
export const utils = {
|
export const utils = {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export type DocumentId = string;
|
||||||
export type RelativePath = string;
|
export type RelativePath = string;
|
||||||
|
|
||||||
export interface DocumentMetadata {
|
export interface DocumentMetadata {
|
||||||
|
documentId: DocumentId;
|
||||||
parentVersionId: VaultUpdateId;
|
parentVersionId: VaultUpdateId;
|
||||||
hash: string;
|
hash: string;
|
||||||
remoteRelativePath?: RelativePath;
|
remoteRelativePath?: RelativePath;
|
||||||
|
|
@ -25,7 +26,6 @@ export interface StoredDocumentMetadata {
|
||||||
export interface StoredDatabase {
|
export interface StoredDatabase {
|
||||||
documents: StoredDocumentMetadata[];
|
documents: StoredDocumentMetadata[];
|
||||||
lastSeenUpdateId: VaultUpdateId | undefined;
|
lastSeenUpdateId: VaultUpdateId | undefined;
|
||||||
hasInitialSyncCompleted: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -36,17 +36,14 @@ export interface StoredDatabase {
|
||||||
*/
|
*/
|
||||||
export interface DocumentRecord {
|
export interface DocumentRecord {
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
documentId: DocumentId;
|
|
||||||
metadata: DocumentMetadata | undefined;
|
metadata: DocumentMetadata | undefined;
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
updates: Promise<unknown>[];
|
|
||||||
parallelVersion: number;
|
parallelVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Database {
|
export class Database {
|
||||||
private documents: DocumentRecord[];
|
private documents: DocumentRecord[];
|
||||||
private lastSeenUpdateIds: CoveredValues;
|
private lastSeenUpdateIds: CoveredValues;
|
||||||
private hasInitialSyncCompleted: boolean;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
|
|
@ -56,16 +53,12 @@ export class Database {
|
||||||
initialState ??= {};
|
initialState ??= {};
|
||||||
|
|
||||||
this.documents =
|
this.documents =
|
||||||
initialState.documents?.map(
|
initialState.documents?.map(({ relativePath, ...metadata }) => ({
|
||||||
({ relativePath, documentId, ...metadata }) => ({
|
|
||||||
relativePath,
|
relativePath,
|
||||||
documentId,
|
|
||||||
metadata,
|
metadata,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
updates: [],
|
|
||||||
parallelVersion: 0
|
parallelVersion: 0
|
||||||
})
|
})) ?? [];
|
||||||
) ?? [];
|
|
||||||
|
|
||||||
this.ensureConsistency();
|
this.ensureConsistency();
|
||||||
this.logger.debug(`Loaded ${this.documents.length} documents`);
|
this.logger.debug(`Loaded ${this.documents.length} documents`);
|
||||||
|
|
@ -79,12 +72,6 @@ export class Database {
|
||||||
this.documents.forEach((doc) => {
|
this.documents.forEach((doc) => {
|
||||||
this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId);
|
this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.hasInitialSyncCompleted =
|
|
||||||
initialState.hasInitialSyncCompleted ?? false;
|
|
||||||
this.logger.debug(
|
|
||||||
`Loaded hasInitialSyncCompleted: ${this.hasInitialSyncCompleted}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get length(): number {
|
public get length(): number {
|
||||||
|
|
@ -127,91 +114,51 @@ export class Database {
|
||||||
|
|
||||||
public updateDocumentMetadata(
|
public updateDocumentMetadata(
|
||||||
metadata: {
|
metadata: {
|
||||||
|
documentId: DocumentId;
|
||||||
parentVersionId: VaultUpdateId;
|
parentVersionId: VaultUpdateId;
|
||||||
hash: string;
|
hash: string;
|
||||||
remoteRelativePath: RelativePath;
|
remoteRelativePath: RelativePath;
|
||||||
},
|
},
|
||||||
toUpdate: DocumentRecord
|
target: DocumentRecord
|
||||||
): void {
|
): void {
|
||||||
if (!this.documents.includes(toUpdate)) {
|
if (!this.documents.includes(target)) {
|
||||||
throw new Error("Document not found in database");
|
throw new Error("Document not found in database");
|
||||||
}
|
}
|
||||||
|
|
||||||
toUpdate.metadata = metadata;
|
this.logger.debug(
|
||||||
|
`Updating document metadata for ${target.relativePath} from ${JSON.stringify(
|
||||||
this.saveInTheBackground();
|
target.metadata,
|
||||||
}
|
null,
|
||||||
|
2
|
||||||
public removeDocumentPromise(promise: Promise<unknown>): void {
|
)} to ${JSON.stringify(metadata, null, 2)}`
|
||||||
const entry = this.documents.find(({ updates }) =>
|
|
||||||
updates.includes(promise)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (entry === undefined) {
|
target.metadata = metadata;
|
||||||
// This method should be idempotent and tolerant of
|
|
||||||
// stragglers calling it after the databse has been reset.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
removeFromArray(entry.updates, promise);
|
|
||||||
// No need to save as Promises don't get serialized
|
|
||||||
}
|
|
||||||
|
|
||||||
public removeDocument(find: DocumentRecord): void {
|
|
||||||
removeFromArray(this.documents, find);
|
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getLatestDocumentByRelativePath(
|
public getLatestDocumentByRelativePath(
|
||||||
find: RelativePath
|
target: RelativePath
|
||||||
): DocumentRecord | undefined {
|
): DocumentRecord | undefined {
|
||||||
const candidates = this.documents.filter(
|
const candidates = this.documents.filter(
|
||||||
({ relativePath }) => relativePath === find
|
({ relativePath }) => relativePath === target
|
||||||
);
|
);
|
||||||
candidates.sort((a, b) => b.parallelVersion - a.parallelVersion); // descending
|
candidates.sort((a, b) => b.parallelVersion - a.parallelVersion); // descending
|
||||||
return candidates[0];
|
return candidates[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getResolvedDocumentByRelativePath(
|
|
||||||
relativePath: RelativePath,
|
|
||||||
promise: Promise<unknown>
|
|
||||||
): Promise<DocumentRecord> {
|
|
||||||
const entry = this.getLatestDocumentByRelativePath(relativePath);
|
|
||||||
|
|
||||||
if (entry === undefined) {
|
|
||||||
throw new Error(
|
|
||||||
`Document not found by relative path: ${relativePath}, ${JSON.stringify(
|
|
||||||
this.documents,
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPromises = entry.updates;
|
|
||||||
entry.updates = [...currentPromises, promise];
|
|
||||||
await awaitAll(currentPromises);
|
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createNewPendingDocument(
|
public createNewPendingDocument(
|
||||||
documentId: DocumentId,
|
relativePath: RelativePath
|
||||||
relativePath: RelativePath,
|
|
||||||
promise: Promise<unknown>
|
|
||||||
): DocumentRecord {
|
): DocumentRecord {
|
||||||
this.logger.debug(
|
this.logger.debug(`Creating new pending document: ${relativePath}`);
|
||||||
`Creating new pending document: ${relativePath} (${documentId})`
|
|
||||||
);
|
|
||||||
const previousEntry =
|
const previousEntry =
|
||||||
this.getLatestDocumentByRelativePath(relativePath);
|
this.getLatestDocumentByRelativePath(relativePath);
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
relativePath,
|
relativePath,
|
||||||
documentId,
|
|
||||||
metadata: undefined,
|
metadata: undefined,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
updates: [promise],
|
|
||||||
parallelVersion:
|
parallelVersion:
|
||||||
previousEntry?.parallelVersion === undefined
|
previousEntry?.parallelVersion === undefined
|
||||||
? 0
|
? 0
|
||||||
|
|
@ -219,39 +166,18 @@ export class Database {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.documents.push(entry);
|
this.documents.push(entry);
|
||||||
this.saveInTheBackground();
|
|
||||||
|
|
||||||
return entry;
|
// no need to save as we only save documents which have metadata
|
||||||
}
|
|
||||||
|
|
||||||
public createNewEmptyDocument(
|
|
||||||
documentId: DocumentId,
|
|
||||||
parentVersionId: VaultUpdateId,
|
|
||||||
relativePath: RelativePath
|
|
||||||
): DocumentRecord {
|
|
||||||
const entry = {
|
|
||||||
relativePath,
|
|
||||||
documentId,
|
|
||||||
metadata: {
|
|
||||||
parentVersionId,
|
|
||||||
hash: EMPTY_HASH,
|
|
||||||
remoteRelativePath: relativePath
|
|
||||||
},
|
|
||||||
isDeleted: false,
|
|
||||||
updates: [],
|
|
||||||
parallelVersion: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
this.documents.push(entry);
|
|
||||||
this.saveInTheBackground();
|
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDocumentByDocumentId(
|
public getDocumentByDocumentId(
|
||||||
find: DocumentId
|
target: DocumentId
|
||||||
): DocumentRecord | undefined {
|
): DocumentRecord | undefined {
|
||||||
return this.documents.find(({ documentId }) => documentId === find);
|
return this.documents.find(
|
||||||
|
({ metadata }) => metadata?.documentId === target
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public move(
|
public move(
|
||||||
|
|
@ -274,7 +200,7 @@ export class Database {
|
||||||
}
|
}
|
||||||
|
|
||||||
oldDocument.relativePath = newRelativePath;
|
oldDocument.relativePath = newRelativePath;
|
||||||
// We're in a strange state where the target of the move has just got deleted,
|
// We might be in a strange state where the target of the move has just got deleted,
|
||||||
// however, its metadata might already have a bunch of updates queued up for
|
// however, its metadata might already have a bunch of updates queued up for
|
||||||
// the document at the new location. We need to keep these updates.
|
// the document at the new location. We need to keep these updates.
|
||||||
oldDocument.parallelVersion =
|
oldDocument.parallelVersion =
|
||||||
|
|
@ -286,19 +212,13 @@ export class Database {
|
||||||
public delete(relativePath: RelativePath): void {
|
public delete(relativePath: RelativePath): void {
|
||||||
const candidate = this.getLatestDocumentByRelativePath(relativePath);
|
const candidate = this.getLatestDocumentByRelativePath(relativePath);
|
||||||
if (candidate === undefined) {
|
if (candidate === undefined) {
|
||||||
throw new Error(
|
return;
|
||||||
`Document not found by relative path: ${relativePath}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
candidate.isDeleted = true;
|
candidate.isDeleted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getHasInitialSyncCompleted(): boolean {
|
public removeDocument(target: DocumentRecord): void {
|
||||||
return this.hasInitialSyncCompleted;
|
removeFromArray(this.documents, target);
|
||||||
}
|
|
||||||
|
|
||||||
public setHasInitialSyncCompleted(value: boolean): void {
|
|
||||||
this.hasInitialSyncCompleted = value;
|
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -324,38 +244,45 @@ export class Database {
|
||||||
this.lastSeenUpdateIds = new CoveredValues(
|
this.lastSeenUpdateIds = new CoveredValues(
|
||||||
0 // the first updateId will be 1 which is the first integer after -1
|
0 // the first updateId will be 1 which is the first integer after -1
|
||||||
);
|
);
|
||||||
this.hasInitialSyncCompleted = false;
|
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async save(): Promise<void> {
|
public async save(): Promise<void> {
|
||||||
return this.saveData({
|
return this.saveData({
|
||||||
documents: this.resolvedDocuments.map(
|
documents: this.resolvedDocuments.map(
|
||||||
({ relativePath, documentId, metadata }) => ({
|
({ relativePath, metadata }) => ({
|
||||||
documentId,
|
|
||||||
relativePath,
|
relativePath,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
...metadata! // `resolvedDocuments` only returns docs with metadata set
|
...metadata! // `resolvedDocuments` only returns docs with metadata set
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
lastSeenUpdateId: this.lastSeenUpdateIds.min,
|
lastSeenUpdateId: this.lastSeenUpdateIds.min
|
||||||
hasInitialSyncCompleted: this.hasInitialSyncCompleted
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureConsistency(): void {
|
private ensureConsistency(): void {
|
||||||
const idToPath = new Map<string, string[]>();
|
const idToPath = new Map<string, string[]>();
|
||||||
|
|
||||||
this.resolvedDocuments.forEach(({ relativePath, documentId }) => {
|
this.resolvedDocuments.forEach(({ relativePath, metadata }) => {
|
||||||
idToPath.set(documentId, [
|
if (metadata === undefined) {
|
||||||
...(idToPath.get(documentId) ?? []),
|
return;
|
||||||
|
}
|
||||||
|
idToPath.set(metadata.documentId, [
|
||||||
|
...(idToPath.get(metadata.documentId) ?? []),
|
||||||
relativePath
|
relativePath
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const duplicates = Array.from(idToPath.entries())
|
const duplicates = Array.from(idToPath.entries())
|
||||||
.filter(([_, paths]) => paths.length > 1)
|
.filter(([_, paths]) => paths.length > 1)
|
||||||
.map(([id, paths]) => `${id} (${paths.join(", ")})`);
|
.map(([id, paths]) => {
|
||||||
|
let details = "";
|
||||||
|
for (const path of paths) {
|
||||||
|
const doc = this.getLatestDocumentByRelativePath(path);
|
||||||
|
details += `\n- ${JSON.stringify(doc, null, 2)}`;
|
||||||
|
}
|
||||||
|
return `${id} (${paths.join(", ")}): ${details}`;
|
||||||
|
});
|
||||||
|
|
||||||
if (duplicates.length > 0) {
|
if (duplicates.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export class Settings {
|
||||||
>();
|
>();
|
||||||
|
|
||||||
private settings: SyncSettings;
|
private settings: SyncSettings;
|
||||||
private readonly lock: Lock = new Lock();
|
private readonly lock: Lock;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
|
|
@ -50,6 +50,8 @@ export class Settings {
|
||||||
...(initialState ?? {})
|
...(initialState ?? {})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.lock = new Lock(Settings.name, this.logger);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Loaded settings: ${JSON.stringify(this.settings, null, 2)}`
|
`Loaded settings: ${JSON.stringify(this.settings, null, 2)}`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { describe, it, mock, beforeEach, afterEach } from "node:test";
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import { FetchController } from "./fetch-controller";
|
import { FetchController } from "./fetch-controller";
|
||||||
import { Logger } from "../tracing/logger";
|
import { Logger } from "../tracing/logger";
|
||||||
import { SyncResetError } from "./sync-reset-error";
|
import { SyncResetError } from "../errors/sync-reset-error";
|
||||||
import { sleep } from "../utils/sleep";
|
import { sleep } from "../utils/sleep";
|
||||||
|
|
||||||
describe("FetchController", () => {
|
describe("FetchController", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Logger } from "../tracing/logger";
|
import type { Logger } from "../tracing/logger";
|
||||||
import { createPromise } from "../utils/create-promise";
|
import { createPromise } from "../utils/create-promise";
|
||||||
import { SyncResetError } from "./sync-reset-error";
|
import { SyncResetError } from "../errors/sync-reset-error";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offers a resettable fetch implementation that waits until syncing is enabled
|
* Offers a resettable fetch implementation that waits until syncing is enabled
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { SUPPORTED_API_VERSION } from "../consts";
|
import { SUPPORTED_API_VERSION } from "../consts";
|
||||||
import { AuthenticationError } from "./authentication-error";
|
import { AuthenticationError } from "../errors/authentication-error";
|
||||||
import { ServerVersionMismatchError } from "./server-version-mismatch-error";
|
import { ServerVersionMismatchError } from "../errors/server-version-mismatch-error";
|
||||||
import type { SyncService } from "./sync-service";
|
import type { SyncService } from "./sync-service";
|
||||||
import type { PingResponse } from "./types/PingResponse";
|
import type { PingResponse } from "./types/PingResponse";
|
||||||
|
|
||||||
|
|
@ -34,11 +34,6 @@ export class ServerConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// warm the cache
|
|
||||||
public async initialize(): Promise<void> {
|
|
||||||
await this.getConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async checkConnection(forceUpdate = false): Promise<{
|
public async checkConnection(forceUpdate = false): Promise<{
|
||||||
isSuccessful: boolean;
|
isSuccessful: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type { Logger } from "../tracing/logger";
|
||||||
import type { Settings } from "../persistence/settings";
|
import type { Settings } from "../persistence/settings";
|
||||||
import type { FetchController } from "./fetch-controller";
|
import type { FetchController } from "./fetch-controller";
|
||||||
import { sleep } from "../utils/sleep";
|
import { sleep } from "../utils/sleep";
|
||||||
import { SyncResetError } from "./sync-reset-error";
|
import { SyncResetError } from "../errors/sync-reset-error";
|
||||||
import type { SerializedError } from "./types/SerializedError";
|
import type { SerializedError } from "./types/SerializedError";
|
||||||
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
||||||
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
|
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
|
||||||
|
|
@ -66,19 +66,15 @@ export class SyncService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async create({
|
public async create({
|
||||||
documentId,
|
|
||||||
relativePath,
|
relativePath,
|
||||||
contentBytes
|
contentBytes
|
||||||
}: {
|
}: {
|
||||||
documentId?: DocumentId;
|
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
contentBytes: Uint8Array;
|
contentBytes: Uint8Array;
|
||||||
}): Promise<DocumentVersionWithoutContent> {
|
}): Promise<DocumentUpdateResponse> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
if (documentId !== undefined) {
|
|
||||||
formData.append("document_id", documentId);
|
|
||||||
}
|
|
||||||
formData.append("relative_path", relativePath);
|
formData.append("relative_path", relativePath);
|
||||||
formData.append(
|
formData.append(
|
||||||
"content",
|
"content",
|
||||||
|
|
@ -86,7 +82,7 @@ export class SyncService {
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Creating document with id ${documentId} and relative path ${relativePath}`
|
`Creating document with relative path ${relativePath}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await this.client(this.getUrl("/documents"), {
|
const response = await this.client(this.getUrl("/documents"), {
|
||||||
|
|
@ -103,8 +99,8 @@ export class SyncService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentVersionWithoutContent =
|
const result: DocumentUpdateResponse =
|
||||||
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(`Created document ${JSON.stringify(result)}`);
|
this.logger.debug(`Created document ${JSON.stringify(result)}`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||||
|
|
||||||
export interface ClientCursors { userName: string, deviceId: string, documentsWithCursors: DocumentWithCursors[], }
|
export interface ClientCursors {
|
||||||
|
userName: string;
|
||||||
|
deviceId: string;
|
||||||
|
documentsWithCursors: DocumentWithCursors[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface CreateDocumentVersion { relative_path: string, content: number[], }
|
export interface CreateDocumentVersion {
|
||||||
|
relative_path: string;
|
||||||
|
content: number[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||||
|
|
||||||
export interface CursorPositionFromClient { documentsWithCursors: DocumentWithCursors[], }
|
export interface CursorPositionFromClient {
|
||||||
|
documentsWithCursors: DocumentWithCursors[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { ClientCursors } from "./ClientCursors";
|
import type { ClientCursors } from "./ClientCursors";
|
||||||
|
|
||||||
export interface CursorPositionFromServer { clients: ClientCursors[], }
|
export interface CursorPositionFromServer {
|
||||||
|
clients: ClientCursors[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface CursorSpan { start: number, end: number, }
|
export interface CursorSpan {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type DeleteDocumentVersion = Record<string, never>;
|
export interface DeleteDocumentVersion {
|
||||||
|
relativePath: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,6 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
|
||||||
/**
|
/**
|
||||||
* Response to an update document request.
|
* Response to an update document request.
|
||||||
*/
|
*/
|
||||||
export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion;
|
export type DocumentUpdateResponse =
|
||||||
|
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
|
||||||
|
| ({ type: "MergingUpdate" } & DocumentVersion);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,12 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface DocumentVersion { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, }
|
export interface DocumentVersion {
|
||||||
|
vaultUpdateId: number;
|
||||||
|
documentId: string;
|
||||||
|
relativePath: string;
|
||||||
|
updatedDate: string;
|
||||||
|
contentBase64: string;
|
||||||
|
isDeleted: boolean;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,12 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface DocumentVersionWithoutContent { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, }
|
export interface DocumentVersionWithoutContent {
|
||||||
|
vaultUpdateId: number;
|
||||||
|
documentId: string;
|
||||||
|
relativePath: string;
|
||||||
|
updatedDate: string;
|
||||||
|
isDeleted: boolean;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
contentSize: number;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { CursorSpan } from "./CursorSpan";
|
import type { CursorSpan } from "./CursorSpan";
|
||||||
|
|
||||||
export interface DocumentWithCursors { vault_update_id: number | null, document_id: string, relative_path: string, cursors: CursorSpan[], }
|
export interface DocumentWithCursors {
|
||||||
|
vault_update_id: number | null;
|
||||||
|
document_id: string;
|
||||||
|
relative_path: string;
|
||||||
|
cursors: CursorSpan[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
|
||||||
/**
|
/**
|
||||||
* Response to a fetch latest documents request.
|
* Response to a fetch latest documents request.
|
||||||
*/
|
*/
|
||||||
export interface FetchLatestDocumentsResponse { latestDocuments: DocumentVersionWithoutContent[],
|
export interface FetchLatestDocumentsResponse {
|
||||||
|
latestDocuments: DocumentVersionWithoutContent[];
|
||||||
/**
|
/**
|
||||||
* The update ID of the latest document in the response.
|
* The update ID of the latest document in the response.
|
||||||
*/
|
*/
|
||||||
lastUpdateId: bigint, }
|
lastUpdateId: bigint;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,19 @@ export interface PingResponse {
|
||||||
/**
|
/**
|
||||||
* Semantic version of the server.
|
* Semantic version of the server.
|
||||||
*/
|
*/
|
||||||
serverVersion: string,
|
serverVersion: string;
|
||||||
/**
|
/**
|
||||||
* Whether the client is authenticated based on the sent Authorization
|
* Whether the client is authenticated based on the sent Authorization
|
||||||
* header.
|
* header.
|
||||||
*/
|
*/
|
||||||
isAuthenticated: boolean,
|
isAuthenticated: boolean;
|
||||||
/**
|
/**
|
||||||
* List of file extensions that are allowed to be merged.
|
* List of file extensions that are allowed to be merged.
|
||||||
*/
|
*/
|
||||||
mergeableFileExtensions: string[],
|
mergeableFileExtensions: string[];
|
||||||
/**
|
/**
|
||||||
* API version ensuring backwards & forwards compatibility between the client
|
* API version ensuring backwards & forwards compatibility between the client
|
||||||
* and server.
|
* and server.
|
||||||
*/
|
*/
|
||||||
supportedApiVersion: number, }
|
supportedApiVersion: number;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface SerializedError { errorType: string, message: string, causes: string[], }
|
export interface SerializedError {
|
||||||
|
errorType: string;
|
||||||
|
message: string;
|
||||||
|
causes: string[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface UpdateTextDocumentVersion { parentVersionId: number, relativePath: string, content: (number | string)[], }
|
export interface UpdateTextDocumentVersion {
|
||||||
|
parentVersionId: number;
|
||||||
|
relativePath: string;
|
||||||
|
content: (number | string)[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,6 @@
|
||||||
import type { CursorPositionFromClient } from "./CursorPositionFromClient";
|
import type { CursorPositionFromClient } from "./CursorPositionFromClient";
|
||||||
import type { WebSocketHandshake } from "./WebSocketHandshake";
|
import type { WebSocketHandshake } from "./WebSocketHandshake";
|
||||||
|
|
||||||
export type WebSocketClientMessage = { "type": "handshake" } & WebSocketHandshake | { "type": "cursorPositions" } & CursorPositionFromClient;
|
export type WebSocketClientMessage =
|
||||||
|
| ({ type: "handshake" } & WebSocketHandshake)
|
||||||
|
| ({ type: "cursorPositions" } & CursorPositionFromClient);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface WebSocketHandshake { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, }
|
export interface WebSocketHandshake {
|
||||||
|
token: string;
|
||||||
|
deviceId: string;
|
||||||
|
lastSeenVaultUpdateId: number | null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,6 @@
|
||||||
import type { CursorPositionFromServer } from "./CursorPositionFromServer";
|
import type { CursorPositionFromServer } from "./CursorPositionFromServer";
|
||||||
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
|
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
|
||||||
|
|
||||||
export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer;
|
export type WebSocketServerMessage =
|
||||||
|
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
|
||||||
|
| ({ type: "cursorPositions" } & CursorPositionFromServer);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||||
|
|
||||||
export interface WebSocketVaultUpdate { documents: DocumentVersionWithoutContent[], isInitialSync: boolean, }
|
export interface WebSocketVaultUpdate {
|
||||||
|
documents: DocumentVersionWithoutContent[];
|
||||||
|
isInitialSync: boolean;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import assert from "node:assert";
|
||||||
import { WebSocketManager } from "./websocket-manager";
|
import { WebSocketManager } from "./websocket-manager";
|
||||||
import type { Logger } from "../tracing/logger";
|
import type { Logger } from "../tracing/logger";
|
||||||
import type { Settings } from "../persistence/settings";
|
import type { Settings } from "../persistence/settings";
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const WebSocket = require("ws") as typeof globalThis.WebSocket;
|
|
||||||
|
|
||||||
class MockCloseEvent extends Event {
|
class MockCloseEvent extends Event {
|
||||||
public code: number;
|
public code: number;
|
||||||
|
|
@ -91,10 +89,8 @@ function createMockFn<T extends (...args: unknown[]) => unknown>(
|
||||||
describe("WebSocketManager", () => {
|
describe("WebSocketManager", () => {
|
||||||
let mockLogger: Logger = undefined as unknown as Logger;
|
let mockLogger: Logger = undefined as unknown as Logger;
|
||||||
let mockSettings: Settings = undefined as unknown as Settings;
|
let mockSettings: Settings = undefined as unknown as Settings;
|
||||||
let deviceId = "test-device-123";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
deviceId = "test-device-123";
|
|
||||||
const noop = (): void => {
|
const noop = (): void => {
|
||||||
// Intentionally empty for mock
|
// Intentionally empty for mock
|
||||||
};
|
};
|
||||||
|
|
@ -116,7 +112,6 @@ describe("WebSocketManager", () => {
|
||||||
|
|
||||||
it("cleans up promises after message handling", async () => {
|
it("cleans up promises after message handling", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
|
|
@ -146,7 +141,6 @@ describe("WebSocketManager", () => {
|
||||||
|
|
||||||
it("cleans up cursor position promises", async () => {
|
it("cleans up cursor position promises", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
|
|
@ -176,7 +170,6 @@ describe("WebSocketManager", () => {
|
||||||
|
|
||||||
it("logs handshake send errors", async () => {
|
it("logs handshake send errors", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
|
|
@ -205,7 +198,6 @@ describe("WebSocketManager", () => {
|
||||||
|
|
||||||
it("completes stop with timeout protection", async () => {
|
it("completes stop with timeout protection", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
|
|
@ -220,7 +212,6 @@ describe("WebSocketManager", () => {
|
||||||
|
|
||||||
it("clears old handlers on reconnection", async () => {
|
it("clears old handlers on reconnection", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
|
|
@ -257,7 +248,6 @@ describe("WebSocketManager", () => {
|
||||||
|
|
||||||
it("tracks message handling promises", async () => {
|
it("tracks message handling promises", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ import type { CursorPositionFromClient } from "./types/CursorPositionFromClient"
|
||||||
import type { ClientCursors } from "./types/ClientCursors";
|
import type { ClientCursors } from "./types/ClientCursors";
|
||||||
import { createPromise } from "../utils/create-promise";
|
import { createPromise } from "../utils/create-promise";
|
||||||
import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate";
|
import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate";
|
||||||
import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_S } from "../consts";
|
import {
|
||||||
|
WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS,
|
||||||
|
WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS
|
||||||
|
} from "../consts";
|
||||||
import { removeFromArray } from "../utils/remove-from-array";
|
import { removeFromArray } from "../utils/remove-from-array";
|
||||||
import { EventListeners } from "../utils/data-structures/event-listeners";
|
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||||
import { awaitAll } from "../utils/await-all";
|
import { awaitAll } from "../utils/await-all";
|
||||||
|
|
@ -27,32 +30,17 @@ export class WebSocketManager {
|
||||||
private isStopped = true;
|
private isStopped = true;
|
||||||
private resolveDisconnectingPromise: null | (() => unknown) = null;
|
private resolveDisconnectingPromise: null | (() => unknown) = null;
|
||||||
private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private connectionTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
private readonly outstandingPromises: Promise<unknown>[] = [];
|
private readonly outstandingPromises: Promise<unknown>[] = [];
|
||||||
|
|
||||||
private webSocket: WebSocket | undefined;
|
private webSocket: WebSocket | undefined;
|
||||||
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly deviceId: string,
|
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly settings: Settings,
|
private readonly settings: Settings,
|
||||||
webSocketImplementation?: typeof globalThis.WebSocket
|
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket = WebSocket
|
||||||
) {
|
) {}
|
||||||
if (webSocketImplementation) {
|
|
||||||
this.webSocketFactoryImplementation = webSocketImplementation;
|
|
||||||
} else {
|
|
||||||
if (
|
|
||||||
typeof globalThis !== "undefined" &&
|
|
||||||
typeof globalThis.WebSocket === "undefined"
|
|
||||||
) {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js
|
|
||||||
} else {
|
|
||||||
this.webSocketFactoryImplementation = WebSocket;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public get isWebSocketConnected(): boolean {
|
public get isWebSocketConnected(): boolean {
|
||||||
return (
|
return (
|
||||||
|
|
@ -77,6 +65,11 @@ export class WebSocketManager {
|
||||||
this.reconnectTimeoutId = undefined;
|
this.reconnectTimeoutId = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.connectionTimeoutId !== undefined) {
|
||||||
|
clearTimeout(this.connectionTimeoutId);
|
||||||
|
this.connectionTimeoutId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
this.webSocket?.close(1000, "WebSocketManager has been stopped");
|
this.webSocket?.close(1000, "WebSocketManager has been stopped");
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||||
|
|
@ -85,10 +78,10 @@ export class WebSocketManager {
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
reject(
|
reject(
|
||||||
new Error(
|
new Error(
|
||||||
`Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_S} seconds`
|
`Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS} seconds`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}, WEBSOCKET_DISCONNECT_TIMEOUT_IN_S * 1000);
|
}, WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS * 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -171,7 +164,10 @@ export class WebSocketManager {
|
||||||
this.webSocket.onclose = null;
|
this.webSocket.onclose = null;
|
||||||
this.webSocket.onmessage = null;
|
this.webSocket.onmessage = null;
|
||||||
this.webSocket.onerror = null;
|
this.webSocket.onerror = null;
|
||||||
this.webSocket.close();
|
this.webSocket.close(
|
||||||
|
1000,
|
||||||
|
"Closing previous WebSocket connection"
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to close previous WebSocket connection: ${e}`
|
`Failed to close previous WebSocket connection: ${e}`
|
||||||
|
|
@ -187,7 +183,22 @@ export class WebSocketManager {
|
||||||
|
|
||||||
this.webSocket = new this.webSocketFactoryImplementation(wsUri);
|
this.webSocket = new this.webSocketFactoryImplementation(wsUri);
|
||||||
|
|
||||||
|
// Set connection timeout to handle cases where server is down and the WebSocket connection won't open
|
||||||
|
this.connectionTimeoutId = setTimeout(() => {
|
||||||
|
this.connectionTimeoutId = undefined;
|
||||||
|
this.logger.warn(
|
||||||
|
`WebSocket connection timeout after ${WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS} seconds`
|
||||||
|
);
|
||||||
|
// Force close to trigger onclose handler which will schedule reconnection
|
||||||
|
this.webSocket?.close(1000, "Connection timeout");
|
||||||
|
}, WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS * 1000);
|
||||||
|
|
||||||
this.webSocket.onopen = (): void => {
|
this.webSocket.onopen = (): void => {
|
||||||
|
if (this.connectionTimeoutId !== undefined) {
|
||||||
|
clearTimeout(this.connectionTimeoutId);
|
||||||
|
this.connectionTimeoutId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we've been stopped while connecting
|
// Check if we've been stopped while connecting
|
||||||
if (this.isStopped) {
|
if (this.isStopped) {
|
||||||
this.webSocket?.close(
|
this.webSocket?.close(
|
||||||
|
|
@ -231,7 +242,18 @@ export class WebSocketManager {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.webSocket.onerror = (error): void => {
|
||||||
|
this.logger.warn(
|
||||||
|
`WebSocket error occurred: ${error instanceof ErrorEvent ? error.message : "Unknown error"}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
this.webSocket.onclose = (event): void => {
|
this.webSocket.onclose = (event): void => {
|
||||||
|
if (this.connectionTimeoutId !== undefined) {
|
||||||
|
clearTimeout(this.connectionTimeoutId);
|
||||||
|
this.connectionTimeoutId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
|
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
|
||||||
);
|
);
|
||||||
|
|
@ -241,10 +263,13 @@ export class WebSocketManager {
|
||||||
this.resolveDisconnectingPromise?.();
|
this.resolveDisconnectingPromise?.();
|
||||||
this.resolveDisconnectingPromise = null;
|
this.resolveDisconnectingPromise = null;
|
||||||
} else {
|
} else {
|
||||||
|
const delay =
|
||||||
|
this.settings.getSettings().webSocketRetryIntervalMs;
|
||||||
|
this.logger.info(`Reconnecting to WebSocket in ${delay}ms...`);
|
||||||
this.reconnectTimeoutId = setTimeout(() => {
|
this.reconnectTimeoutId = setTimeout(() => {
|
||||||
this.reconnectTimeoutId = undefined;
|
this.reconnectTimeoutId = undefined;
|
||||||
this.initializeWebSocket();
|
this.initializeWebSocket();
|
||||||
}, this.settings.getSettings().webSocketRetryIntervalMs);
|
}, delay);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ import { ServerConfig } from "./services/server-config";
|
||||||
import type { EventListeners } from "./utils/data-structures/event-listeners";
|
import type { EventListeners } from "./utils/data-structures/event-listeners";
|
||||||
|
|
||||||
export class SyncClient {
|
export class SyncClient {
|
||||||
private hasStartedOfflineSync = false;
|
|
||||||
private hasFinishedOfflineSync = false;
|
private hasFinishedOfflineSync = false;
|
||||||
private hasStarted = false;
|
private hasStarted = false;
|
||||||
private hasBeenDestroyed = false;
|
private hasBeenDestroyed = false;
|
||||||
|
|
@ -38,12 +37,12 @@ export class SyncClient {
|
||||||
private readonly eventUnsubscribers: (() => void)[] = [];
|
private readonly eventUnsubscribers: (() => void)[] = [];
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
|
public readonly logger: Logger,
|
||||||
private readonly history: SyncHistory,
|
private readonly history: SyncHistory,
|
||||||
private readonly settings: Settings,
|
private readonly settings: Settings,
|
||||||
private readonly database: Database,
|
private readonly database: Database,
|
||||||
private readonly syncer: Syncer,
|
private readonly syncer: Syncer,
|
||||||
private readonly webSocketManager: WebSocketManager,
|
private readonly webSocketManager: WebSocketManager,
|
||||||
public readonly logger: Logger,
|
|
||||||
private readonly fetchController: FetchController,
|
private readonly fetchController: FetchController,
|
||||||
private readonly cursorTracker: CursorTracker,
|
private readonly cursorTracker: CursorTracker,
|
||||||
private readonly fileChangeNotifier: FileChangeNotifier,
|
private readonly fileChangeNotifier: FileChangeNotifier,
|
||||||
|
|
@ -195,7 +194,6 @@ export class SyncClient {
|
||||||
);
|
);
|
||||||
|
|
||||||
const webSocketManager = new WebSocketManager(
|
const webSocketManager = new WebSocketManager(
|
||||||
deviceId,
|
|
||||||
logger,
|
logger,
|
||||||
settings,
|
settings,
|
||||||
webSocket
|
webSocket
|
||||||
|
|
@ -206,7 +204,6 @@ export class SyncClient {
|
||||||
logger,
|
logger,
|
||||||
database,
|
database,
|
||||||
settings,
|
settings,
|
||||||
syncService,
|
|
||||||
webSocketManager,
|
webSocketManager,
|
||||||
fileOperations,
|
fileOperations,
|
||||||
unrestrictedSyncer
|
unrestrictedSyncer
|
||||||
|
|
@ -214,18 +211,19 @@ export class SyncClient {
|
||||||
|
|
||||||
const fileChangeNotifier = new FileChangeNotifier();
|
const fileChangeNotifier = new FileChangeNotifier();
|
||||||
const cursorTracker = new CursorTracker(
|
const cursorTracker = new CursorTracker(
|
||||||
|
logger,
|
||||||
database,
|
database,
|
||||||
webSocketManager,
|
webSocketManager,
|
||||||
fileOperations,
|
fileOperations,
|
||||||
fileChangeNotifier
|
fileChangeNotifier
|
||||||
);
|
);
|
||||||
const client = new SyncClient(
|
const client = new SyncClient(
|
||||||
|
logger,
|
||||||
history,
|
history,
|
||||||
settings,
|
settings,
|
||||||
database,
|
database,
|
||||||
syncer,
|
syncer,
|
||||||
webSocketManager,
|
webSocketManager,
|
||||||
logger,
|
|
||||||
fetchController,
|
fetchController,
|
||||||
cursorTracker,
|
cursorTracker,
|
||||||
fileChangeNotifier,
|
fileChangeNotifier,
|
||||||
|
|
@ -337,12 +335,13 @@ export class SyncClient {
|
||||||
this.database.reset();
|
this.database.reset();
|
||||||
await this.database.save(); // ensure the new database reads as empty
|
await this.database.save(); // ensure the new database reads as empty
|
||||||
this.resetInMemoryState();
|
this.resetInMemoryState();
|
||||||
this.hasStartedOfflineSync = false;
|
|
||||||
this.hasFinishedOfflineSync = false;
|
this.hasFinishedOfflineSync = false;
|
||||||
this.serverConfig.reset();
|
this.serverConfig.reset();
|
||||||
|
|
||||||
|
if (this.settings.getSettings().isSyncEnabled) {
|
||||||
await this.startSyncing();
|
await this.startSyncing();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public getSettings(): SyncSettings {
|
public getSettings(): SyncSettings {
|
||||||
return this.settings.getSettings();
|
return this.settings.getSettings();
|
||||||
|
|
@ -410,12 +409,7 @@ export class SyncClient {
|
||||||
return DocumentSyncStatus.SYNCING;
|
return DocumentSyncStatus.SYNCING;
|
||||||
}
|
}
|
||||||
|
|
||||||
const document =
|
return this.syncer.hasPendingOperationsForDocument(relativePath)
|
||||||
this.database.getLatestDocumentByRelativePath(relativePath);
|
|
||||||
if (document === undefined) {
|
|
||||||
return DocumentSyncStatus.SYNCING;
|
|
||||||
}
|
|
||||||
return document.updates.length > 0
|
|
||||||
? DocumentSyncStatus.SYNCING
|
? DocumentSyncStatus.SYNCING
|
||||||
: DocumentSyncStatus.UP_TO_DATE;
|
: DocumentSyncStatus.UP_TO_DATE;
|
||||||
}
|
}
|
||||||
|
|
@ -473,18 +467,17 @@ export class SyncClient {
|
||||||
this.checkIfDestroyed("startSyncing");
|
this.checkIfDestroyed("startSyncing");
|
||||||
this.fetchController.finishReset();
|
this.fetchController.finishReset();
|
||||||
|
|
||||||
await this.serverConfig.initialize();
|
// warm the cache
|
||||||
this.webSocketManager.start();
|
await this.serverConfig.getConfig();
|
||||||
|
|
||||||
if (!this.hasStartedOfflineSync) {
|
|
||||||
this.hasStartedOfflineSync = true;
|
|
||||||
await this.syncer.scheduleSyncForOfflineChanges();
|
await this.syncer.scheduleSyncForOfflineChanges();
|
||||||
}
|
this.webSocketManager.start();
|
||||||
|
|
||||||
this.hasFinishedOfflineSync = true;
|
this.hasFinishedOfflineSync = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async pause(): Promise<void> {
|
private async pause(): Promise<void> {
|
||||||
|
this.hasFinishedOfflineSync = false;
|
||||||
this.fetchController.startReset();
|
this.fetchController.startReset();
|
||||||
await this.webSocketManager.stop();
|
await this.webSocketManager.stop();
|
||||||
await this.waitUntilFinished();
|
await this.waitUntilFinished();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { hash } from "../utils/hash";
|
||||||
import type { FileChangeNotifier } from "./file-change-notifier";
|
import type { FileChangeNotifier } from "./file-change-notifier";
|
||||||
import { Lock } from "../utils/data-structures/locks";
|
import { Lock } from "../utils/data-structures/locks";
|
||||||
import { EventListeners } from "../utils/data-structures/event-listeners";
|
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||||
|
import { Logger } from "../tracing/logger";
|
||||||
|
|
||||||
// Cursor positions are updated separately from documents. However, a given cursor position is only
|
// Cursor positions are updated separately from documents. However, a given cursor position is only
|
||||||
// valid within a certain version of the document it belongs to. This class tracks previous and the latest
|
// valid within a certain version of the document it belongs to. This class tracks previous and the latest
|
||||||
|
|
@ -22,7 +23,7 @@ export class CursorTracker {
|
||||||
(cursors: MaybeOutdatedClientCursors[]) => unknown
|
(cursors: MaybeOutdatedClientCursors[]) => unknown
|
||||||
>();
|
>();
|
||||||
|
|
||||||
private readonly updateLock = new Lock();
|
private readonly updateLock: Lock;
|
||||||
|
|
||||||
private knownRemoteCursors: (ClientCursors & {
|
private knownRemoteCursors: (ClientCursors & {
|
||||||
upToDateness: DocumentUpToDateness;
|
upToDateness: DocumentUpToDateness;
|
||||||
|
|
@ -33,11 +34,14 @@ export class CursorTracker {
|
||||||
[];
|
[];
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly logger: Logger,
|
||||||
private readonly database: Database,
|
private readonly database: Database,
|
||||||
private readonly webSocketManager: WebSocketManager,
|
private readonly webSocketManager: WebSocketManager,
|
||||||
private readonly fileOperations: FileOperations,
|
private readonly fileOperations: FileOperations,
|
||||||
private readonly fileChangeNotifier: FileChangeNotifier
|
private readonly fileChangeNotifier: FileChangeNotifier
|
||||||
) {
|
) {
|
||||||
|
this.updateLock = new Lock(CursorTracker.name, logger);
|
||||||
|
|
||||||
this.webSocketManager.onRemoteCursorsUpdateReceived.add(
|
this.webSocketManager.onRemoteCursorsUpdateReceived.add(
|
||||||
async (clientCursors) => {
|
async (clientCursors) => {
|
||||||
await this.updateLock.withLock(async () => {
|
await this.updateLock.withLock(async () => {
|
||||||
|
|
@ -113,7 +117,7 @@ export class CursorTracker {
|
||||||
|
|
||||||
documentsWithCursors.push({
|
documentsWithCursors.push({
|
||||||
relative_path: relativePath,
|
relative_path: relativePath,
|
||||||
document_id: record.documentId,
|
document_id: record.metadata.documentId,
|
||||||
vault_update_id: record.metadata.parentVersionId,
|
vault_update_id: record.metadata.parentVersionId,
|
||||||
cursors: cursors.map(({ start, end }) => ({
|
cursors: cursors.map(({ start, end }) => ({
|
||||||
start: Math.min(start, end),
|
start: Math.min(start, end),
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,14 @@ import type {
|
||||||
DocumentRecord,
|
DocumentRecord,
|
||||||
RelativePath
|
RelativePath
|
||||||
} from "../persistence/database";
|
} from "../persistence/database";
|
||||||
import type { SyncService } from "../services/sync-service";
|
|
||||||
import type { Logger } from "../tracing/logger";
|
import type { Logger } from "../tracing/logger";
|
||||||
import PQueue from "p-queue";
|
import PQueue from "p-queue";
|
||||||
import { hash } from "../utils/hash";
|
import { hash } from "../utils/hash";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import type { Settings } from "../persistence/settings";
|
import type { Settings } from "../persistence/settings";
|
||||||
import type { FileOperations } from "../file-operations/file-operations";
|
import type { FileOperations } from "../file-operations/file-operations";
|
||||||
import { findMatchingFile } from "../utils/find-matching-file";
|
import { findMatchingFile } from "../utils/find-matching-file";
|
||||||
import type { UnrestrictedSyncer } from "./unrestricted-syncer";
|
import type { UnrestrictedSyncer } from "./unrestricted-syncer";
|
||||||
import { createPromise } from "../utils/create-promise";
|
import { SyncResetError } from "../errors/sync-reset-error";
|
||||||
import { SyncResetError } from "../services/sync-reset-error";
|
|
||||||
import { Locks } from "../utils/data-structures/locks";
|
import { Locks } from "../utils/data-structures/locks";
|
||||||
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
|
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
|
||||||
import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdate";
|
import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdate";
|
||||||
|
|
@ -28,7 +25,7 @@ export class Syncer {
|
||||||
(remainingOperations: number) => unknown
|
(remainingOperations: number) => unknown
|
||||||
>();
|
>();
|
||||||
|
|
||||||
private readonly remoteDocumentsLock: Locks<DocumentId>;
|
public readonly updatedDocumentsByPathAndKeysLocks: Locks<string>; // can be DocumentId or RelativePath
|
||||||
|
|
||||||
// FIFO to limit the number of concurrent sync operations
|
// FIFO to limit the number of concurrent sync operations
|
||||||
private readonly syncQueue: PQueue;
|
private readonly syncQueue: PQueue;
|
||||||
|
|
@ -42,16 +39,18 @@ export class Syncer {
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly database: Database,
|
private readonly database: Database,
|
||||||
private readonly settings: Settings,
|
private readonly settings: Settings,
|
||||||
private readonly syncService: SyncService,
|
|
||||||
private readonly webSocketManager: WebSocketManager,
|
private readonly webSocketManager: WebSocketManager,
|
||||||
private readonly operations: FileOperations,
|
private readonly operations: FileOperations,
|
||||||
private readonly internalSyncer: UnrestrictedSyncer
|
private readonly unrestrictedSyncer: UnrestrictedSyncer
|
||||||
) {
|
) {
|
||||||
this.syncQueue = new PQueue({
|
this.syncQueue = new PQueue({
|
||||||
concurrency: settings.getSettings().syncConcurrency
|
concurrency: settings.getSettings().syncConcurrency
|
||||||
});
|
});
|
||||||
|
|
||||||
this.remoteDocumentsLock = new Locks<DocumentId>(this.logger);
|
this.updatedDocumentsByPathAndKeysLocks = new Locks<DocumentId>(
|
||||||
|
Syncer.name,
|
||||||
|
this.logger
|
||||||
|
);
|
||||||
|
|
||||||
settings.onSettingsChanged.add((newSettings, oldSettings) => {
|
settings.onSettingsChanged.add((newSettings, oldSettings) => {
|
||||||
if (newSettings.syncConcurrency !== oldSettings.syncConcurrency) {
|
if (newSettings.syncConcurrency !== oldSettings.syncConcurrency) {
|
||||||
|
|
@ -83,52 +82,50 @@ export class Syncer {
|
||||||
return this._isFirstSyncComplete;
|
return this._isFirstSyncComplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hasPendingOperationsForDocument(relativePath: string): boolean {
|
||||||
|
return this.updatedDocumentsByPathAndKeysLocks.isLocked(relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
public async syncLocallyCreatedFile(
|
public async syncLocallyCreatedFile(
|
||||||
relativePath: RelativePath
|
relativePath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// check whether someone else has already created the document in the database
|
||||||
if (
|
if (
|
||||||
this.database.getLatestDocumentByRelativePath(relativePath)
|
this.database.getLatestDocumentByRelativePath(relativePath)
|
||||||
?.isDeleted === false
|
?.isDeleted === false
|
||||||
) {
|
) {
|
||||||
|
// This is likely a consequence of us creating a file because of a remote update
|
||||||
|
// which triggered a local create, so we don't need to do anything here.
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Document ${relativePath} already exists in the database, skipping`
|
`Document ${relativePath} already exists in the database, skipping`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [promise, resolve, reject] = createPromise();
|
const document = this.database.createNewPendingDocument(relativePath);
|
||||||
|
|
||||||
const id = uuidv4();
|
await this.enqueueSyncOperation(
|
||||||
const document = this.database.createNewPendingDocument(
|
async () =>
|
||||||
id,
|
this.unrestrictedSyncer.unrestrictedSyncLocallyCreatedOrUpdatedFile(
|
||||||
relativePath,
|
{
|
||||||
promise
|
document
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.syncQueue.add(async () =>
|
|
||||||
this.internalSyncer.unrestrictedSyncLocallyCreatedFile(document)
|
|
||||||
);
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
} catch (e) {
|
|
||||||
reject(e);
|
|
||||||
} finally {
|
|
||||||
this.database.removeDocumentPromise(promise);
|
|
||||||
}
|
}
|
||||||
|
),
|
||||||
|
[relativePath]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async syncLocallyDeletedFile(
|
public async syncLocallyDeletedFile(
|
||||||
relativePath: RelativePath
|
relativePath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
let document =
|
||||||
this.database.getLatestDocumentByRelativePath(relativePath)
|
this.database.getLatestDocumentByRelativePath(relativePath);
|
||||||
?.isDeleted === true
|
|
||||||
) {
|
if (document == null || document.isDeleted === true) {
|
||||||
// This is must be a consequence of us deleting a file because of a remote update
|
// This is must be a consequence of us deleting a file because of a remote update
|
||||||
// which triggered a local delete, so we don't need to do anything here.
|
// which triggered a local delete, so we don't need to do anything here.
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Document ${relativePath} has already been markes as deleted, skipping`
|
`Document ${relativePath} has already been marked as deleted, skipping`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -137,26 +134,13 @@ export class Syncer {
|
||||||
// document which finishes after the delete has succeeded and would introduce a phantom metadata record.
|
// document which finishes after the delete has succeeded and would introduce a phantom metadata record.
|
||||||
this.database.delete(relativePath);
|
this.database.delete(relativePath);
|
||||||
|
|
||||||
const [promise, resolve, reject] = createPromise();
|
await this.enqueueSyncOperation(async () => {
|
||||||
|
await this.unrestrictedSyncer.unrestrictedSyncLocallyDeletedFile(
|
||||||
const document = await this.database.getResolvedDocumentByRelativePath(
|
document
|
||||||
relativePath,
|
|
||||||
promise
|
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
|
||||||
await this.syncQueue.add(async () =>
|
|
||||||
this.internalSyncer.unrestrictedSyncLocallyDeletedFile(document)
|
|
||||||
);
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
|
|
||||||
this.database.removeDocument(document);
|
this.database.removeDocument(document);
|
||||||
} catch (e) {
|
}, [document?.metadata?.documentId, relativePath]);
|
||||||
reject(e);
|
|
||||||
} finally {
|
|
||||||
this.database.removeDocumentPromise(promise);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async syncLocallyUpdatedFile({
|
public async syncLocallyUpdatedFile({
|
||||||
|
|
@ -166,38 +150,10 @@ export class Syncer {
|
||||||
oldPath?: RelativePath;
|
oldPath?: RelativePath;
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (oldPath !== undefined) {
|
const document =
|
||||||
// We might have moved the document in the database before calling this method,
|
this.database.getLatestDocumentByRelativePath(oldPath ?? relativePath);
|
||||||
// in that case, we mustn't move it again.
|
|
||||||
if (
|
|
||||||
this.database.getLatestDocumentByRelativePath(relativePath) ===
|
|
||||||
undefined ||
|
|
||||||
this.database.getLatestDocumentByRelativePath(relativePath)
|
|
||||||
?.isDeleted === true
|
|
||||||
) {
|
|
||||||
if (oldPath === relativePath) {
|
|
||||||
throw new Error(
|
|
||||||
`Old path and new path are the same: ${oldPath}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.database.move(oldPath, relativePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let document =
|
|
||||||
this.database.getLatestDocumentByRelativePath(relativePath);
|
|
||||||
|
|
||||||
if (
|
|
||||||
oldPath !== undefined &&
|
|
||||||
document?.metadata?.remoteRelativePath === relativePath
|
|
||||||
) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Document ${relativePath} has been moved as a result of a remote update, skipping sync`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// must have been removed after a successful delete
|
||||||
if (document === undefined) {
|
if (document === undefined) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Cannot find document ${relativePath} in the database, skipping`
|
`Cannot find document ${relativePath} in the database, skipping`
|
||||||
|
|
@ -212,27 +168,47 @@ export class Syncer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [promise, resolve, reject] = createPromise();
|
const documentAtNewPath =
|
||||||
|
this.database.getLatestDocumentByRelativePath(relativePath);
|
||||||
|
|
||||||
document = await this.database.getResolvedDocumentByRelativePath(
|
if (oldPath !== undefined) {
|
||||||
relativePath,
|
// We might have moved the document in the database before calling this method,
|
||||||
promise
|
// in that case, we mustn't move it again.
|
||||||
|
if (
|
||||||
|
documentAtNewPath === undefined ||
|
||||||
|
documentAtNewPath.isDeleted
|
||||||
|
) {
|
||||||
|
if (oldPath === relativePath) {
|
||||||
|
throw new Error(
|
||||||
|
`Old path and new path are the same: ${oldPath}`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
this.database.move(oldPath, relativePath);
|
||||||
await this.syncQueue.add(async () =>
|
}
|
||||||
this.internalSyncer.unrestrictedSyncLocallyUpdatedFile({
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (
|
||||||
|
oldPath !== undefined &&
|
||||||
|
document?.metadata?.remoteRelativePath === relativePath
|
||||||
|
) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Document ${relativePath} has been moved as a result of a remote update, skipping sync`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.enqueueSyncOperation(
|
||||||
|
async () =>
|
||||||
|
this.unrestrictedSyncer.unrestrictedSyncLocallyCreatedOrUpdatedFile(
|
||||||
|
{
|
||||||
oldPath,
|
oldPath,
|
||||||
document
|
document
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
} catch (e) {
|
|
||||||
reject(e);
|
|
||||||
} finally {
|
|
||||||
this.database.removeDocumentPromise(promise);
|
|
||||||
}
|
}
|
||||||
|
),
|
||||||
|
[document.metadata?.documentId, relativePath, oldPath]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async scheduleSyncForOfflineChanges(): Promise<void> {
|
public async scheduleSyncForOfflineChanges(): Promise<void> {
|
||||||
|
|
@ -257,8 +233,6 @@ export class Syncer {
|
||||||
`Not all local changes have been applied remotely: ${e}`
|
`Not all local changes have been applied remotely: ${e}`
|
||||||
);
|
);
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
|
||||||
this.runningScheduleSyncForOfflineChanges = undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,6 +245,8 @@ export class Syncer {
|
||||||
message: WebSocketVaultUpdate
|
message: WebSocketVaultUpdate
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
await this.scheduleSyncForOfflineChanges();
|
||||||
|
|
||||||
const handlerPromise = awaitAll(
|
const handlerPromise = awaitAll(
|
||||||
message.documents.map(async (document) =>
|
message.documents.map(async (document) =>
|
||||||
this.internalSyncRemotelyUpdatedFile(document)
|
this.internalSyncRemotelyUpdatedFile(document)
|
||||||
|
|
@ -296,7 +272,7 @@ export class Syncer {
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this._isFirstSyncComplete = false;
|
this._isFirstSyncComplete = false;
|
||||||
this.syncQueue.clear();
|
this.syncQueue.clear();
|
||||||
this.remoteDocumentsLock.reset();
|
this.updatedDocumentsByPathAndKeysLocks.reset();
|
||||||
this.runningScheduleSyncForOfflineChanges = undefined;
|
this.runningScheduleSyncForOfflineChanges = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -313,86 +289,26 @@ export class Syncer {
|
||||||
private async internalSyncRemotelyUpdatedFile(
|
private async internalSyncRemotelyUpdatedFile(
|
||||||
remoteVersion: DocumentVersionWithoutContent
|
remoteVersion: DocumentVersionWithoutContent
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let document = this.database.getDocumentByDocumentId(
|
const document = this.database.getDocumentByDocumentId(
|
||||||
remoteVersion.documentId
|
remoteVersion.documentId
|
||||||
);
|
);
|
||||||
|
await this.enqueueSyncOperation(
|
||||||
if (document === undefined) {
|
async () =>
|
||||||
// Let's avoid the same documents getting created in parallel multiple times.
|
this.unrestrictedSyncer.unrestrictedSyncRemotelyUpdatedFile(
|
||||||
// There might be multiple tasks waiting for the lock
|
remoteVersion,
|
||||||
return this.remoteDocumentsLock.withLock(
|
document
|
||||||
remoteVersion.documentId,
|
),
|
||||||
async () => {
|
[
|
||||||
document = this.database.getDocumentByDocumentId(
|
document?.relativePath,
|
||||||
|
remoteVersion.relativePath,
|
||||||
remoteVersion.documentId
|
remoteVersion.documentId
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile`
|
|
||||||
if (document === undefined) {
|
|
||||||
await this.syncQueue.add(async () =>
|
|
||||||
this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile(
|
|
||||||
remoteVersion
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const [promise, resolve, reject] = createPromise();
|
|
||||||
|
|
||||||
document =
|
|
||||||
await this.database.getResolvedDocumentByRelativePath(
|
|
||||||
document.relativePath,
|
|
||||||
promise
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.syncQueue.add(async () =>
|
|
||||||
this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile(
|
|
||||||
remoteVersion,
|
|
||||||
document
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
} catch (e) {
|
|
||||||
reject(e);
|
|
||||||
} finally {
|
|
||||||
this.database.removeDocumentPromise(promise);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.database.addSeenUpdateId(remoteVersion.vaultUpdateId);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile`
|
|
||||||
const [promise, resolve, reject] = createPromise();
|
|
||||||
|
|
||||||
document = await this.database.getResolvedDocumentByRelativePath(
|
|
||||||
document.relativePath,
|
|
||||||
promise
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.syncQueue.add(async () =>
|
|
||||||
this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile(
|
|
||||||
remoteVersion,
|
|
||||||
document
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
} catch (e) {
|
|
||||||
reject(e);
|
|
||||||
} finally {
|
|
||||||
this.database.removeDocumentPromise(promise);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.database.addSeenUpdateId(remoteVersion.vaultUpdateId);
|
this.database.addSeenUpdateId(remoteVersion.vaultUpdateId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async internalScheduleSyncForOfflineChanges(): Promise<void> {
|
private async internalScheduleSyncForOfflineChanges(): Promise<void> {
|
||||||
await this.createFakeDocumentsFromRemoteState();
|
|
||||||
|
|
||||||
const allLocalFiles = await this.operations.listFilesRecursively();
|
const allLocalFiles = await this.operations.listFilesRecursively();
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
`Scheduling sync for ${allLocalFiles.length} local files`
|
`Scheduling sync for ${allLocalFiles.length} local files`
|
||||||
|
|
@ -409,7 +325,12 @@ export class Syncer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await awaitAll(
|
interface Instruction {
|
||||||
|
type: "update" | "create";
|
||||||
|
relativePath: string;
|
||||||
|
oldPath?: string;
|
||||||
|
}
|
||||||
|
const instructions: (Instruction | undefined)[] = await awaitAll(
|
||||||
allLocalFiles.map(async (relativePath) => {
|
allLocalFiles.map(async (relativePath) => {
|
||||||
if (
|
if (
|
||||||
this.database.getLatestDocumentByRelativePath(relativePath)
|
this.database.getLatestDocumentByRelativePath(relativePath)
|
||||||
|
|
@ -419,16 +340,24 @@ export class Syncer {
|
||||||
`Document ${relativePath} might have been updated locally, scheduling sync to validate and update it`
|
`Document ${relativePath} might have been updated locally, scheduling sync to validate and update it`
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.syncLocallyUpdatedFile({
|
return { type: "update", relativePath } as Instruction;
|
||||||
relativePath
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perhaps the file has been moved; let's check by looking at the deleted files
|
// Perhaps the file has been moved; let's check by looking at the deleted files
|
||||||
const contentHash = await this.syncQueue.add(async () => {
|
const contentHash = await this.syncQueue.add(async () => {
|
||||||
|
try {
|
||||||
const contentBytes =
|
const contentBytes =
|
||||||
await this.operations.read(relativePath); // this can throw FileNotFoundError
|
await this.operations.read(relativePath); // this can throw FileNotFoundError
|
||||||
return hash(contentBytes);
|
return hash(contentBytes);
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e instanceof Error &&
|
||||||
|
e.name === "FileNotFoundError"
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (contentHash == undefined) {
|
if (contentHash == undefined) {
|
||||||
|
|
@ -454,18 +383,21 @@ export class Syncer {
|
||||||
`Document '${originalFile.relativePath}' was not found under its current path in the database but was found under a different path (${relativePath}), scheduling sync to move it`
|
`Document '${originalFile.relativePath}' was not found under its current path in the database but was found under a different path (${relativePath}), scheduling sync to move it`
|
||||||
);
|
);
|
||||||
|
|
||||||
// We're outside of the pqueue, so we need to call the public wrapper
|
return {
|
||||||
return this.syncLocallyUpdatedFile({
|
type: "update",
|
||||||
oldPath: originalFile.relativePath,
|
oldPath: originalFile.relativePath,
|
||||||
relativePath
|
relativePath
|
||||||
});
|
} as Instruction;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Document ${relativePath} not found in database, scheduling sync to create it`
|
`Document ${relativePath} not found in database, scheduling sync to create it`
|
||||||
);
|
);
|
||||||
// We're outside of the pqueue, so we need to call the public wrapper
|
|
||||||
return this.syncLocallyCreatedFile(relativePath);
|
return {
|
||||||
|
type: "create",
|
||||||
|
relativePath
|
||||||
|
} as Instruction;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -481,42 +413,49 @@ export class Syncer {
|
||||||
return this.syncLocallyDeletedFile(relativePath);
|
return this.syncLocallyDeletedFile(relativePath);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
await awaitAll(
|
||||||
* Create fake documents in the database for all files that are present locally
|
instructions.map(async (instruction) => {
|
||||||
* and also exist remotely. This will stop the subequent syncs from duplicating
|
if (instruction === undefined) {
|
||||||
* the documents by creating the same documents from multiple clients.
|
|
||||||
*/
|
|
||||||
private async createFakeDocumentsFromRemoteState(): Promise<void> {
|
|
||||||
if (this.database.getHasInitialSyncCompleted()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [allLocalFiles, remote] = await awaitAll([
|
if (instruction.type === "update") {
|
||||||
this.operations.listFilesRecursively(),
|
// We're outside of the pqueue, so we need to call the public wrapper
|
||||||
this.syncQueue.add(async () => this.syncService.getAll())
|
await this.syncLocallyUpdatedFile({
|
||||||
]);
|
oldPath: instruction.oldPath,
|
||||||
|
relativePath: instruction.relativePath
|
||||||
if (remote !== undefined) {
|
|
||||||
remote.latestDocuments
|
|
||||||
.filter(
|
|
||||||
(remoteDocument) =>
|
|
||||||
allLocalFiles.includes(remoteDocument.relativePath) &&
|
|
||||||
!remoteDocument.isDeleted &&
|
|
||||||
this.database.getDocumentByDocumentId(
|
|
||||||
remoteDocument.documentId
|
|
||||||
) === undefined
|
|
||||||
)
|
|
||||||
.forEach((remoteDocument) => {
|
|
||||||
this.database.createNewEmptyDocument(
|
|
||||||
remoteDocument.documentId,
|
|
||||||
remoteDocument.vaultUpdateId,
|
|
||||||
remoteDocument.relativePath
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// we have to ensure the deletes & updates have finished before starting creates,
|
||||||
|
// otherwise the server might return an existing document (that we're about to delete)
|
||||||
|
// instead of actually creating a new one
|
||||||
|
await awaitAll(
|
||||||
|
instructions.map(async (instruction) => {
|
||||||
|
if (instruction === undefined) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.database.setHasInitialSyncCompleted(true);
|
if (instruction.type === "create") {
|
||||||
|
// We're outside of the pqueue, so we need to call the public wrapper
|
||||||
|
await this.syncLocallyCreatedFile(instruction.relativePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enqueueSyncOperation<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
keys: (string | undefined | null)[]
|
||||||
|
): Promise<T> {
|
||||||
|
return this.updatedDocumentsByPathAndKeysLocks.withLock(
|
||||||
|
keys.filter((k) => k !== undefined && k !== null),
|
||||||
|
async () => this.syncQueue.add(operation)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import type {
|
||||||
DocumentRecord,
|
DocumentRecord,
|
||||||
RelativePath
|
RelativePath
|
||||||
} from "../persistence/database";
|
} from "../persistence/database";
|
||||||
|
|
||||||
import { diff } from "reconcile-text";
|
import { diff } from "reconcile-text";
|
||||||
import type { SyncService } from "../services/sync-service";
|
import type { SyncService } from "../services/sync-service";
|
||||||
import type { Logger } from "../tracing/logger";
|
import type { Logger } from "../tracing/logger";
|
||||||
|
|
@ -18,13 +17,11 @@ import type {
|
||||||
} from "../tracing/sync-history";
|
} from "../tracing/sync-history";
|
||||||
import { SyncStatus, SyncType } from "../tracing/sync-history";
|
import { SyncStatus, SyncType } from "../tracing/sync-history";
|
||||||
import { EMPTY_HASH, hash } from "../utils/hash";
|
import { EMPTY_HASH, hash } from "../utils/hash";
|
||||||
|
|
||||||
import { base64ToBytes } from "byte-base64";
|
import { base64ToBytes } from "byte-base64";
|
||||||
import type { Settings } from "../persistence/settings";
|
import type { Settings } from "../persistence/settings";
|
||||||
import type { FileOperations } from "../file-operations/file-operations";
|
import type { FileOperations } from "../file-operations/file-operations";
|
||||||
import { createPromise } from "../utils/create-promise";
|
import { FileNotFoundError } from "../errors/file-not-found-error";
|
||||||
import { FileNotFoundError } from "../file-operations/file-not-found-error";
|
import { SyncResetError } from "../errors/sync-reset-error";
|
||||||
import { SyncResetError } from "../services/sync-reset-error";
|
|
||||||
import { globsToRegexes } from "../utils/globs-to-regexes";
|
import { globsToRegexes } from "../utils/globs-to-regexes";
|
||||||
import type { DocumentVersion } from "../services/types/DocumentVersion";
|
import type { DocumentVersion } from "../services/types/DocumentVersion";
|
||||||
import type { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse";
|
import type { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse";
|
||||||
|
|
@ -60,115 +57,27 @@ export class UnrestrictedSyncer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrictedSyncLocallyCreatedFile(
|
public async unrestrictedSyncLocallyCreatedOrUpdatedFile({
|
||||||
document: DocumentRecord
|
|
||||||
): Promise<void> {
|
|
||||||
const updateDetails: SyncCreateDetails = {
|
|
||||||
type: SyncType.CREATE,
|
|
||||||
relativePath: document.relativePath
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.executeSync(updateDetails, async () => {
|
|
||||||
const originalRelativePath = document.relativePath;
|
|
||||||
if (document.isDeleted) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Document ${originalRelativePath} has been already deleted, no need to create it`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentBytes =
|
|
||||||
await this.operations.read(originalRelativePath); // this can throw FileNotFoundError
|
|
||||||
const contentHash = hash(contentBytes);
|
|
||||||
|
|
||||||
const response = await this.syncService.create({
|
|
||||||
documentId: document.documentId,
|
|
||||||
relativePath: originalRelativePath,
|
|
||||||
contentBytes
|
|
||||||
});
|
|
||||||
|
|
||||||
// In case a document with the same name (but different ID) had existed remotely that we haven't known about
|
|
||||||
if (response.relativePath != originalRelativePath) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Document ${originalRelativePath} has been created remotely at a different path: ${response.relativePath}, moving it locally`
|
|
||||||
);
|
|
||||||
await this.operations.move(
|
|
||||||
document.relativePath,
|
|
||||||
response.relativePath
|
|
||||||
); // this can throw FileNotFoundError
|
|
||||||
}
|
|
||||||
|
|
||||||
this.database.updateDocumentMetadata(
|
|
||||||
{
|
|
||||||
parentVersionId: response.vaultUpdateId,
|
|
||||||
hash: contentHash,
|
|
||||||
remoteRelativePath: response.relativePath
|
|
||||||
},
|
|
||||||
document
|
|
||||||
);
|
|
||||||
|
|
||||||
this.database.addSeenUpdateId(response.vaultUpdateId);
|
|
||||||
await this.updateCache(
|
|
||||||
response.vaultUpdateId,
|
|
||||||
contentBytes,
|
|
||||||
response.relativePath
|
|
||||||
);
|
|
||||||
|
|
||||||
this.history.addHistoryEntry({
|
|
||||||
status: SyncStatus.SUCCESS,
|
|
||||||
details: updateDetails,
|
|
||||||
message: `Successfully uploaded locally created file`
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async unrestrictedSyncLocallyDeletedFile(
|
|
||||||
document: DocumentRecord
|
|
||||||
): Promise<void> {
|
|
||||||
const updateDetails: SyncDeleteDetails = {
|
|
||||||
type: SyncType.DELETE,
|
|
||||||
relativePath: document.relativePath
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.executeSync(updateDetails, async () => {
|
|
||||||
const response = await this.syncService.delete({
|
|
||||||
documentId: document.documentId,
|
|
||||||
relativePath: document.relativePath
|
|
||||||
});
|
|
||||||
|
|
||||||
this.database.updateDocumentMetadata(
|
|
||||||
{
|
|
||||||
parentVersionId: response.vaultUpdateId,
|
|
||||||
hash: EMPTY_HASH,
|
|
||||||
remoteRelativePath: document.relativePath
|
|
||||||
},
|
|
||||||
document
|
|
||||||
);
|
|
||||||
|
|
||||||
this.database.addSeenUpdateId(response.vaultUpdateId);
|
|
||||||
|
|
||||||
this.history.addHistoryEntry({
|
|
||||||
status: SyncStatus.SUCCESS,
|
|
||||||
details: updateDetails,
|
|
||||||
message: `Successfully deleted locally deleted file on the server`,
|
|
||||||
author: response.userId
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async unrestrictedSyncLocallyUpdatedFile({
|
|
||||||
oldPath,
|
oldPath,
|
||||||
document,
|
|
||||||
// We use the same code path for both local and remote updates. We need to force the update
|
// We use the same code path for both local and remote updates. We need to force the update
|
||||||
// if there are no local changes but we know that the remote version is newer.
|
// if there are no local changes but we know that the remote version is newer.
|
||||||
force = false
|
force = false,
|
||||||
|
document
|
||||||
}: {
|
}: {
|
||||||
oldPath?: RelativePath;
|
oldPath?: RelativePath;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
document: DocumentRecord;
|
document: DocumentRecord;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const updateDetails: SyncUpdateDetails | SyncMovedDetails =
|
const updateDetails:
|
||||||
oldPath !== undefined
|
| SyncCreateDetails
|
||||||
|
| SyncUpdateDetails
|
||||||
|
| SyncMovedDetails =
|
||||||
|
document.metadata === undefined
|
||||||
|
? {
|
||||||
|
type: SyncType.CREATE,
|
||||||
|
relativePath: document.relativePath
|
||||||
|
}
|
||||||
|
: oldPath !== undefined
|
||||||
? {
|
? {
|
||||||
type: SyncType.MOVE,
|
type: SyncType.MOVE,
|
||||||
relativePath: document.relativePath,
|
relativePath: document.relativePath,
|
||||||
|
|
@ -182,7 +91,7 @@ export class UnrestrictedSyncer {
|
||||||
await this.executeSync(updateDetails, async () => {
|
await this.executeSync(updateDetails, async () => {
|
||||||
const originalRelativePath = document.relativePath;
|
const originalRelativePath = document.relativePath;
|
||||||
|
|
||||||
if (document.isDeleted || document.metadata === undefined) {
|
if (document.isDeleted) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Document ${document.relativePath} has been already deleted, no need to update it`
|
`Document ${document.relativePath} has been already deleted, no need to update it`
|
||||||
);
|
);
|
||||||
|
|
@ -192,14 +101,28 @@ export class UnrestrictedSyncer {
|
||||||
const contentBytes = await this.operations.read(
|
const contentBytes = await this.operations.read(
|
||||||
document.relativePath
|
document.relativePath
|
||||||
); // this can throw FileNotFoundError
|
); // this can throw FileNotFoundError
|
||||||
let contentHash = hash(contentBytes);
|
const contentHash = hash(contentBytes);
|
||||||
|
|
||||||
const areThereLocalChanges = !(
|
|
||||||
document.metadata.hash === contentHash && oldPath === undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
let response: DocumentVersion | DocumentUpdateResponse | undefined =
|
let response: DocumentVersion | DocumentUpdateResponse | undefined =
|
||||||
undefined;
|
undefined;
|
||||||
|
if (document.metadata === undefined) {
|
||||||
|
response = await this.syncService.create({
|
||||||
|
relativePath: originalRelativePath,
|
||||||
|
contentBytes
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.handleMaybeMergingResponse({
|
||||||
|
document,
|
||||||
|
response,
|
||||||
|
contentHash,
|
||||||
|
originalRelativePath,
|
||||||
|
originalContentBytes: contentBytes,
|
||||||
|
isCreate: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const areThereLocalChanges =
|
||||||
|
document.metadata.hash !== contentHash ||
|
||||||
|
oldPath !== undefined;
|
||||||
|
|
||||||
if (areThereLocalChanges) {
|
if (areThereLocalChanges) {
|
||||||
const isText =
|
const isText =
|
||||||
|
|
@ -216,7 +139,7 @@ export class UnrestrictedSyncer {
|
||||||
response =
|
response =
|
||||||
isText && cachedVersion !== undefined
|
isText && cachedVersion !== undefined
|
||||||
? await this.syncService.putText({
|
? await this.syncService.putText({
|
||||||
documentId: document.documentId,
|
documentId: document.metadata.documentId,
|
||||||
parentVersionId:
|
parentVersionId:
|
||||||
document.metadata.parentVersionId,
|
document.metadata.parentVersionId,
|
||||||
relativePath: document.relativePath,
|
relativePath: document.relativePath,
|
||||||
|
|
@ -226,7 +149,7 @@ export class UnrestrictedSyncer {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
: await this.syncService.putBinary({
|
: await this.syncService.putBinary({
|
||||||
documentId: document.documentId,
|
documentId: document.metadata.documentId,
|
||||||
parentVersionId:
|
parentVersionId:
|
||||||
document.metadata.parentVersionId,
|
document.metadata.parentVersionId,
|
||||||
relativePath: document.relativePath,
|
relativePath: document.relativePath,
|
||||||
|
|
@ -240,98 +163,32 @@ export class UnrestrictedSyncer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// we use this code path (force == true) to sync remotely updated files which have no local changes
|
||||||
response = await this.syncService.get({
|
response = await this.syncService.get({
|
||||||
documentId: document.documentId
|
documentId: document.metadata.documentId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// `document` is mutable and reflects the latest state in the local database
|
await this.handleMaybeMergingResponse({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
document,
|
||||||
if (document.isDeleted) {
|
response,
|
||||||
this.logger.info(
|
contentHash,
|
||||||
`Document ${document.relativePath} has been deleted before we could finish updating it`
|
originalRelativePath,
|
||||||
);
|
originalContentBytes: contentBytes
|
||||||
this.database.addSeenUpdateId(response.vaultUpdateId);
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
// `Syncer` creates fake local document metadata for all remote docs with invalid hashes. The parent IDs will likely match
|
|
||||||
// the latest versions so we still need to update the local versions to turn the fakes into real metadata.
|
|
||||||
document.metadata.parentVersionId > response.vaultUpdateId
|
|
||||||
) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Document ${document.relativePath} is already more up to date than the fetched version`
|
|
||||||
);
|
|
||||||
this.database.addSeenUpdateId(response.vaultUpdateId); // in case the previous `vaultUpdateId` update hasn't made it through
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.isDeleted) {
|
|
||||||
return this.applyRemoteDeleteLocally(document, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
let actualPath = document.relativePath;
|
|
||||||
|
|
||||||
if (response.relativePath != originalRelativePath) {
|
|
||||||
actualPath = response.relativePath;
|
|
||||||
// Make sure to update the remote relative path to avoid uploading
|
|
||||||
// the file as a result of this filesystem event.
|
|
||||||
document.metadata.remoteRelativePath = response.relativePath;
|
|
||||||
await this.operations.move(
|
|
||||||
document.relativePath,
|
|
||||||
response.relativePath
|
|
||||||
); // this can throw FileNotFoundError
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!("type" in response) || response.type === "MergingUpdate") {
|
if (!("type" in response) || response.type === "MergingUpdate") {
|
||||||
const responseBytes = base64ToBytes(response.contentBase64);
|
|
||||||
contentHash = hash(responseBytes);
|
|
||||||
|
|
||||||
this.database.updateDocumentMetadata(
|
|
||||||
{
|
|
||||||
parentVersionId: response.vaultUpdateId,
|
|
||||||
hash: contentHash,
|
|
||||||
remoteRelativePath: response.relativePath
|
|
||||||
},
|
|
||||||
document
|
|
||||||
);
|
|
||||||
await this.operations.write(
|
|
||||||
actualPath,
|
|
||||||
contentBytes,
|
|
||||||
responseBytes
|
|
||||||
);
|
|
||||||
await this.updateCache(
|
|
||||||
response.vaultUpdateId,
|
|
||||||
responseBytes,
|
|
||||||
actualPath
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!force) {
|
if (!force) {
|
||||||
this.history.addHistoryEntry({
|
this.history.addHistoryEntry({
|
||||||
status: SyncStatus.SUCCESS,
|
status: SyncStatus.SUCCESS,
|
||||||
details: updateDetails,
|
details: updateDetails,
|
||||||
message: `The file we updated had been updated remotely, so we downloaded the merged version`
|
message: `The file we updated had been updated remotely, so we downloaded the merged version`
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
this.database.updateDocumentMetadata(
|
|
||||||
{
|
|
||||||
parentVersionId: response.vaultUpdateId,
|
|
||||||
hash: contentHash,
|
|
||||||
remoteRelativePath: response.relativePath
|
|
||||||
},
|
|
||||||
document
|
|
||||||
);
|
|
||||||
await this.updateCache(
|
|
||||||
response.vaultUpdateId,
|
|
||||||
contentBytes,
|
|
||||||
actualPath
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.database.addSeenUpdateId(response.vaultUpdateId);
|
|
||||||
|
|
||||||
const actualUpdateDetails: SyncUpdateDetails | SyncMovedDetails =
|
const actualUpdateDetails: SyncUpdateDetails | SyncMovedDetails =
|
||||||
oldPath !== undefined ||
|
oldPath !== undefined ||
|
||||||
response.relativePath != originalRelativePath
|
response.relativePath != originalRelativePath
|
||||||
|
|
@ -345,14 +202,7 @@ export class UnrestrictedSyncer {
|
||||||
relativePath: response.relativePath
|
relativePath: response.relativePath
|
||||||
};
|
};
|
||||||
|
|
||||||
if (areThereLocalChanges) {
|
if (!response.isDeleted) {
|
||||||
this.history.addHistoryEntry({
|
|
||||||
status: SyncStatus.SUCCESS,
|
|
||||||
details: actualUpdateDetails,
|
|
||||||
message: `Successfully uploaded locally updated file to the server`,
|
|
||||||
author: response.userId
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.history.addHistoryEntry({
|
this.history.addHistoryEntry({
|
||||||
status: SyncStatus.SUCCESS,
|
status: SyncStatus.SUCCESS,
|
||||||
details: actualUpdateDetails,
|
details: actualUpdateDetails,
|
||||||
|
|
@ -360,10 +210,64 @@ export class UnrestrictedSyncer {
|
||||||
author: response.userId,
|
author: response.userId,
|
||||||
timestamp: new Date(response.updatedDate)
|
timestamp: new Date(response.updatedDate)
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
this.history.addHistoryEntry({
|
||||||
|
status: SyncStatus.SUCCESS,
|
||||||
|
details: {
|
||||||
|
type: SyncType.DELETE,
|
||||||
|
relativePath: document.relativePath
|
||||||
|
},
|
||||||
|
message:
|
||||||
|
"Successfully deleted file which had been deleted remotely",
|
||||||
|
author: response.userId,
|
||||||
|
timestamp: new Date(response.updatedDate)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async unrestrictedSyncLocallyDeletedFile(
|
||||||
|
document: DocumentRecord
|
||||||
|
): Promise<void> {
|
||||||
|
const updateDetails: SyncDeleteDetails = {
|
||||||
|
type: SyncType.DELETE,
|
||||||
|
relativePath: document.relativePath
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.executeSync(updateDetails, async () => {
|
||||||
|
if (document.metadata === undefined) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Document ${document.relativePath} has never been synced, no need to delete it remotely`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.syncService.delete({
|
||||||
|
documentId: document.metadata.documentId,
|
||||||
|
relativePath: document.relativePath
|
||||||
|
});
|
||||||
|
|
||||||
|
this.database.updateDocumentMetadata(
|
||||||
|
{
|
||||||
|
documentId: response.documentId,
|
||||||
|
parentVersionId: response.vaultUpdateId,
|
||||||
|
hash: EMPTY_HASH,
|
||||||
|
remoteRelativePath: document.relativePath
|
||||||
|
},
|
||||||
|
document
|
||||||
|
);
|
||||||
|
|
||||||
|
this.database.addSeenUpdateId(response.vaultUpdateId);
|
||||||
|
|
||||||
|
this.history.addHistoryEntry({
|
||||||
|
status: SyncStatus.SUCCESS,
|
||||||
|
details: updateDetails,
|
||||||
|
message: `Successfully deleted locally deleted file on the server`,
|
||||||
|
author: response.userId
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async unrestrictedSyncRemotelyUpdatedFile(
|
public async unrestrictedSyncRemotelyUpdatedFile(
|
||||||
remoteVersion: DocumentVersionWithoutContent,
|
remoteVersion: DocumentVersionWithoutContent,
|
||||||
document?: DocumentRecord
|
document?: DocumentRecord
|
||||||
|
|
@ -382,13 +286,13 @@ export class UnrestrictedSyncer {
|
||||||
remoteVersion.vaultUpdateId
|
remoteVersion.vaultUpdateId
|
||||||
) {
|
) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Document ${remoteVersion.relativePath} is already at least as up to date as the fetched version`
|
`Document ${document.relativePath} is already at least as up-to-date as the fetched version`
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.unrestrictedSyncLocallyUpdatedFile({
|
return this.unrestrictedSyncLocallyCreatedOrUpdatedFile({
|
||||||
document,
|
document,
|
||||||
force: true
|
force: true
|
||||||
});
|
});
|
||||||
|
|
@ -434,17 +338,15 @@ export class UnrestrictedSyncer {
|
||||||
|
|
||||||
await this.operations.ensureClearPath(remoteVersion.relativePath);
|
await this.operations.ensureClearPath(remoteVersion.relativePath);
|
||||||
|
|
||||||
const [promise, resolve] = createPromise();
|
|
||||||
this.database.updateDocumentMetadata(
|
this.database.updateDocumentMetadata(
|
||||||
{
|
{
|
||||||
|
documentId: remoteVersion.documentId,
|
||||||
parentVersionId: remoteVersion.vaultUpdateId,
|
parentVersionId: remoteVersion.vaultUpdateId,
|
||||||
hash: hash(contentBytes),
|
hash: hash(contentBytes),
|
||||||
remoteRelativePath: remoteVersion.relativePath
|
remoteRelativePath: remoteVersion.relativePath
|
||||||
},
|
},
|
||||||
this.database.createNewPendingDocument(
|
this.database.createNewPendingDocument(
|
||||||
remoteVersion.documentId,
|
remoteVersion.relativePath
|
||||||
remoteVersion.relativePath,
|
|
||||||
promise
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -458,9 +360,6 @@ export class UnrestrictedSyncer {
|
||||||
remoteVersion.relativePath
|
remoteVersion.relativePath
|
||||||
);
|
);
|
||||||
|
|
||||||
resolve();
|
|
||||||
this.database.removeDocumentPromise(promise);
|
|
||||||
|
|
||||||
this.history.addHistoryEntry({
|
this.history.addHistoryEntry({
|
||||||
status: SyncStatus.SUCCESS,
|
status: SyncStatus.SUCCESS,
|
||||||
details: updateDetails,
|
details: updateDetails,
|
||||||
|
|
@ -471,10 +370,17 @@ export class UnrestrictedSyncer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async executeSync<T>(
|
private async executeSync<T>(
|
||||||
details: SyncDetails,
|
details: SyncDetails,
|
||||||
fn: () => Promise<T>
|
fn: () => Promise<T>
|
||||||
): Promise<T | undefined> {
|
): Promise<T | undefined> {
|
||||||
|
if (!this.settings.getSettings().isSyncEnabled) {
|
||||||
|
this.logger.info(
|
||||||
|
`Skipping sync operation for file '${details.relativePath}' because sync is disabled`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const pattern of this.ignorePatterns) {
|
for (const pattern of this.ignorePatterns) {
|
||||||
if (pattern.test(details.relativePath)) {
|
if (pattern.test(details.relativePath)) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
|
|
@ -528,6 +434,127 @@ export class UnrestrictedSyncer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleMaybeMergingResponse({
|
||||||
|
document,
|
||||||
|
response,
|
||||||
|
contentHash,
|
||||||
|
originalRelativePath,
|
||||||
|
originalContentBytes,
|
||||||
|
isCreate
|
||||||
|
}: {
|
||||||
|
document: DocumentRecord;
|
||||||
|
response: DocumentVersion | DocumentUpdateResponse;
|
||||||
|
contentHash: string;
|
||||||
|
originalRelativePath: string;
|
||||||
|
originalContentBytes: Uint8Array;
|
||||||
|
isCreate?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
// `document` is mutable and reflects the latest state in the local database
|
||||||
|
if (document.isDeleted) {
|
||||||
|
this.logger.info(
|
||||||
|
`Document ${document.relativePath} has been deleted before we could finish updating it`
|
||||||
|
);
|
||||||
|
this.database.addSeenUpdateId(response.vaultUpdateId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(document.metadata?.parentVersionId ?? 0) > response.vaultUpdateId
|
||||||
|
) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Document ${document.relativePath} is already more up to date than the fetched version`
|
||||||
|
);
|
||||||
|
this.database.addSeenUpdateId(response.vaultUpdateId); // in case the previous `vaultUpdateId` update hasn't made it through
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.isDeleted) {
|
||||||
|
return this.applyRemoteDeleteLocally(document, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
let actualPath = document.relativePath;
|
||||||
|
|
||||||
|
if (isCreate) {
|
||||||
|
// We have a file locally that got moved by another client to the same path as the one we're trying to create.
|
||||||
|
// The server returns a merging update for the document ID that already exists locally (but at another path).
|
||||||
|
// We have to merge these two documents by extending the provenance of the existing document and deleting
|
||||||
|
// the old document that the new document already contains the content for.
|
||||||
|
const existingDocument = this.database.getDocumentByDocumentId(
|
||||||
|
response.documentId
|
||||||
|
);
|
||||||
|
if (existingDocument !== undefined) {
|
||||||
|
this.logger.info(
|
||||||
|
`Merging existing document ${existingDocument.relativePath} into ${document.relativePath
|
||||||
|
} after concurrent move & creation`
|
||||||
|
);
|
||||||
|
if (!existingDocument.isDeleted) {
|
||||||
|
this.database.delete(existingDocument.relativePath); // make sure syncLocallyDeletedFile doesn't actually schedule deleting the new file
|
||||||
|
this.database.removeDocument(existingDocument);
|
||||||
|
await this.operations.move(existingDocument.relativePath, document.relativePath);
|
||||||
|
} else {
|
||||||
|
this.database.removeDocument(existingDocument);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// this can't happen on the creation path as we can only get a merging response if a document already exists remotely on the same path
|
||||||
|
if (response.relativePath != originalRelativePath) {
|
||||||
|
actualPath = response.relativePath;
|
||||||
|
// Make sure to update the remote relative path to avoid uploading
|
||||||
|
// the file as a result of this filesystem event.
|
||||||
|
if (document.metadata !== undefined) {
|
||||||
|
document.metadata.remoteRelativePath = response.relativePath;
|
||||||
|
}
|
||||||
|
await this.operations.move(
|
||||||
|
document.relativePath,
|
||||||
|
response.relativePath
|
||||||
|
); // this can throw FileNotFoundError
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("type" in response) || response.type === "MergingUpdate") {
|
||||||
|
const responseBytes = base64ToBytes(response.contentBase64);
|
||||||
|
contentHash = hash(responseBytes);
|
||||||
|
|
||||||
|
this.database.updateDocumentMetadata(
|
||||||
|
{
|
||||||
|
documentId: response.documentId,
|
||||||
|
parentVersionId: response.vaultUpdateId,
|
||||||
|
hash: contentHash,
|
||||||
|
remoteRelativePath: response.relativePath
|
||||||
|
},
|
||||||
|
document
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.operations.write(
|
||||||
|
actualPath,
|
||||||
|
originalContentBytes,
|
||||||
|
responseBytes
|
||||||
|
);
|
||||||
|
await this.updateCache(
|
||||||
|
response.vaultUpdateId,
|
||||||
|
responseBytes,
|
||||||
|
actualPath
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.database.updateDocumentMetadata(
|
||||||
|
{
|
||||||
|
documentId: response.documentId,
|
||||||
|
parentVersionId: response.vaultUpdateId,
|
||||||
|
hash: contentHash,
|
||||||
|
remoteRelativePath: response.relativePath
|
||||||
|
},
|
||||||
|
document
|
||||||
|
);
|
||||||
|
await this.updateCache(
|
||||||
|
response.vaultUpdateId,
|
||||||
|
originalContentBytes,
|
||||||
|
actualPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.database.addSeenUpdateId(response.vaultUpdateId);
|
||||||
|
}
|
||||||
|
|
||||||
private getHistoryEntryForSkippedOversizedFile(
|
private getHistoryEntryForSkippedOversizedFile(
|
||||||
sizeInBytes: number,
|
sizeInBytes: number,
|
||||||
relativePath: RelativePath
|
relativePath: RelativePath
|
||||||
|
|
@ -541,8 +568,7 @@ export class UnrestrictedSyncer {
|
||||||
type: SyncType.SKIPPED,
|
type: SyncType.SKIPPED,
|
||||||
relativePath
|
relativePath
|
||||||
},
|
},
|
||||||
message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${
|
message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${maxFileSizeMB
|
||||||
maxFileSizeMB
|
|
||||||
} MB`
|
} MB`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -568,20 +594,10 @@ export class UnrestrictedSyncer {
|
||||||
document: DocumentRecord,
|
document: DocumentRecord,
|
||||||
response: DocumentVersion | DocumentUpdateResponse
|
response: DocumentVersion | DocumentUpdateResponse
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.history.addHistoryEntry({
|
|
||||||
status: SyncStatus.SUCCESS,
|
|
||||||
details: {
|
|
||||||
type: SyncType.DELETE,
|
|
||||||
relativePath: document.relativePath
|
|
||||||
},
|
|
||||||
message: "File has been deleted remotely, so we deleted it locally",
|
|
||||||
author: response.userId,
|
|
||||||
timestamp: new Date(response.updatedDate)
|
|
||||||
});
|
|
||||||
|
|
||||||
this.database.delete(document.relativePath);
|
this.database.delete(document.relativePath);
|
||||||
this.database.updateDocumentMetadata(
|
this.database.updateDocumentMetadata(
|
||||||
{
|
{
|
||||||
|
documentId: response.documentId,
|
||||||
parentVersionId: response.vaultUpdateId,
|
parentVersionId: response.vaultUpdateId,
|
||||||
hash: EMPTY_HASH,
|
hash: EMPTY_HASH,
|
||||||
remoteRelativePath: response.relativePath
|
remoteRelativePath: response.relativePath
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ type ResolvedTuple<T extends readonly unknown[]> = {
|
||||||
export const awaitAll = async <T extends readonly unknown[]>(
|
export const awaitAll = async <T extends readonly unknown[]>(
|
||||||
promises: PromiseTuple<T>
|
promises: PromiseTuple<T>
|
||||||
): Promise<ResolvedTuple<T>> => {
|
): Promise<ResolvedTuple<T>> => {
|
||||||
// eslint-disable-next-line no-restricted-properties
|
// eslint-disable-next-line no-restricted-properties, @typescript-eslint/await-thenable
|
||||||
const result = await Promise.allSettled(promises);
|
const result = await Promise.allSettled(promises);
|
||||||
for (const res of result) {
|
for (const res of result) {
|
||||||
if (res.status === "rejected") {
|
if (res.status === "rejected") {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
|
|
||||||
export function createClientId(): string {
|
export function createClientId(): string {
|
||||||
// @ts-expect-error, injected by webpack
|
// @ts-expect-error, injected by webpack
|
||||||
const packageVersion = __CURRENT_VERSION__; // eslint-disable-line
|
const packageVersion = __CURRENT_VERSION__; // eslint-disable-line
|
||||||
|
|
@ -11,5 +9,5 @@ export function createClientId(): string {
|
||||||
? process.platform
|
? process.platform
|
||||||
: "unknown";
|
: "unknown";
|
||||||
|
|
||||||
return `vault-link/${packageVersion} (${uuidv4()}; ${platform})`;
|
return `vault-link/${packageVersion} (${Math.round(Math.random() * 1e10)}; ${platform})`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,20 @@ import type { RelativePath } from "../../persistence/database";
|
||||||
import { Locks } from "./locks";
|
import { Locks } from "./locks";
|
||||||
import { awaitAll } from "../await-all";
|
import { awaitAll } from "../await-all";
|
||||||
import { sleep } from "../sleep";
|
import { sleep } from "../sleep";
|
||||||
import { SyncResetError } from "../../services/sync-reset-error";
|
import { SyncResetError } from "../../errors/sync-reset-error";
|
||||||
|
|
||||||
describe("withLock", () => {
|
describe("withLock", () => {
|
||||||
const testPath: RelativePath = "test/document/path";
|
const testPath: RelativePath = "test/document/path";
|
||||||
const testPath2: RelativePath = "test/document/path2";
|
const testPath2: RelativePath = "test/document/path2";
|
||||||
|
const testPath3: RelativePath = "test/document/path3";
|
||||||
|
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||||
let locks: Locks<RelativePath>;
|
let locks: Locks<RelativePath>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
locks = new Locks<RelativePath>(logger);
|
locks = new Locks<RelativePath>("locks-test", logger);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should execute function with single key lock", async () => {
|
it("should execute function with single key lock", async () => {
|
||||||
|
|
@ -56,22 +58,32 @@ describe("withLock", () => {
|
||||||
it("should sort multiple keys to prevent deadlocks", async () => {
|
it("should sort multiple keys to prevent deadlocks", async () => {
|
||||||
const executionOrder: string[] = [];
|
const executionOrder: string[] = [];
|
||||||
|
|
||||||
// Start two concurrent operations with keys in different orders
|
await locks.waitForLock(testPath);
|
||||||
const promise1 = locks.withLock([testPath2, testPath], async () => {
|
|
||||||
|
const promise = awaitAll([
|
||||||
|
locks.withLock([testPath2, testPath3, testPath], async () => {
|
||||||
executionOrder.push("operation1-start");
|
executionOrder.push("operation1-start");
|
||||||
await sleep(50);
|
|
||||||
executionOrder.push("operation1-end");
|
executionOrder.push("operation1-end");
|
||||||
return "result1";
|
return "result1";
|
||||||
});
|
}),
|
||||||
|
|
||||||
const promise2 = locks.withLock([testPath, testPath2], async () => {
|
locks.withLock([testPath3, testPath, testPath2], async () => {
|
||||||
executionOrder.push("operation2-start");
|
executionOrder.push("operation2-start");
|
||||||
await sleep(50);
|
|
||||||
executionOrder.push("operation2-end");
|
executionOrder.push("operation2-end");
|
||||||
return "result2";
|
return "result2";
|
||||||
});
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
const [result1, result2] = await awaitAll([promise1, promise2]);
|
locks.unlock(testPath);
|
||||||
|
|
||||||
|
const [result1, result2] = await Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error("Deadlock detected"));
|
||||||
|
}, 1000);
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
assert.strictEqual(result1, "result1");
|
assert.strictEqual(result1, "result1");
|
||||||
assert.strictEqual(result2, "result2");
|
assert.strictEqual(result2, "result2");
|
||||||
|
|
@ -234,13 +246,14 @@ describe("withLock", () => {
|
||||||
|
|
||||||
describe("reset", () => {
|
describe("reset", () => {
|
||||||
const testPath: RelativePath = "test/document/path";
|
const testPath: RelativePath = "test/document/path";
|
||||||
|
const testPath2: RelativePath = "test/document/path2";
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||||
let locks: Locks<RelativePath>;
|
let locks: Locks<RelativePath>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
locks = new Locks<RelativePath>(logger);
|
locks = new Locks<RelativePath>("locks-test", logger);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject pending waiters with SyncResetError while running operation completes", async () => {
|
it("should reject pending waiters with SyncResetError while running operation completes", async () => {
|
||||||
|
|
@ -289,4 +302,38 @@ describe("reset", () => {
|
||||||
const result = await locks.withLock(testPath, () => "success");
|
const result = await locks.withLock(testPath, () => "success");
|
||||||
assert.strictEqual(result, "success");
|
assert.strictEqual(result, "success");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should release partially acquired locks when reset interrupts multi-key acquisition", async () => {
|
||||||
|
// Hold testPath2 so multi-key acquisition will block on it
|
||||||
|
await locks.waitForLock(testPath2);
|
||||||
|
|
||||||
|
// Start multi-key lock that will acquire testPath first, then block on testPath2
|
||||||
|
const multiKeyPromise = locks.withLock(
|
||||||
|
[testPath, testPath2],
|
||||||
|
async () => "multi"
|
||||||
|
);
|
||||||
|
void multiKeyPromise.catch(() => { }); // eslint-disable-line @typescript-eslint/no-empty-function
|
||||||
|
|
||||||
|
// Wait for the multi-key operation to acquire testPath and start waiting on testPath2
|
||||||
|
await sleep(10);
|
||||||
|
|
||||||
|
// Reset should reject the waiting operation
|
||||||
|
locks.reset();
|
||||||
|
|
||||||
|
await assert.rejects(multiKeyPromise, (err: Error) => {
|
||||||
|
assert.ok(err instanceof SyncResetError);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The key that was already acquired (testPath) should now be released
|
||||||
|
// This would hang/timeout if the lock was leaked
|
||||||
|
const result = await Promise.race([
|
||||||
|
locks.withLock(testPath, () => "success"),
|
||||||
|
sleep(100).then(() => {
|
||||||
|
throw new Error("Lock was not released - deadlock detected");
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.strictEqual(result, "success");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { SyncResetError } from "../../services/sync-reset-error";
|
import { SyncResetError } from "../../errors/sync-reset-error";
|
||||||
import type { Logger } from "../../tracing/logger";
|
import type { Logger } from "../../tracing/logger";
|
||||||
import { awaitAll } from "../await-all";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages exclusive locks on items to prevent concurrent modifications.
|
* Manages exclusive locks on items to prevent concurrent modifications.
|
||||||
|
|
@ -8,17 +7,20 @@ import { awaitAll } from "../await-all";
|
||||||
*
|
*
|
||||||
* @template T The type of the key used for locking
|
* @template T The type of the key used for locking
|
||||||
*/
|
*/
|
||||||
|
/** Waiter entry with callbacks */
|
||||||
|
interface WaiterEntry<T> {
|
||||||
|
resolve: () => unknown;
|
||||||
|
reject: (err: unknown) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export class Locks<T> {
|
export class Locks<T> {
|
||||||
/** Currently locked keys */
|
/** Currently locked keys */
|
||||||
private readonly locked = new Set<T>();
|
private readonly locked = new Set<T>();
|
||||||
|
|
||||||
/** Queue of resolve functions waiting for each key */
|
/** Queue of waiters for each key */
|
||||||
private readonly waiters = new Map<
|
private readonly waiters = new Map<T, WaiterEntry<T>[]>();
|
||||||
T,
|
|
||||||
[() => unknown, (err: unknown) => unknown][]
|
|
||||||
>();
|
|
||||||
|
|
||||||
public constructor(private readonly logger?: Logger) {}
|
public constructor(private readonly name: string, private readonly logger?: Logger) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a function while holding exclusive locks on one or more keys.
|
* Executes a function while holding exclusive locks on one or more keys.
|
||||||
|
|
@ -59,12 +61,17 @@ export class Locks<T> {
|
||||||
const uniqueKeys = Array.from(new Set(keys));
|
const uniqueKeys = Array.from(new Set(keys));
|
||||||
uniqueKeys.sort((a, b) => String(a).localeCompare(String(b))); // Ensure consistent order to prevent deadlocks
|
uniqueKeys.sort((a, b) => String(a).localeCompare(String(b))); // Ensure consistent order to prevent deadlocks
|
||||||
|
|
||||||
await awaitAll(uniqueKeys.map(async (key) => this.waitForLock(key)));
|
const lockedKeys = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
for (const key of uniqueKeys) {
|
||||||
|
// Must acquire locks in-order (not concurrently) to prevent deadlocks
|
||||||
|
await this.waitForLock(key);
|
||||||
|
lockedKeys.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
uniqueKeys.forEach((key) => {
|
lockedKeys.forEach((key) => {
|
||||||
this.unlock(key);
|
this.unlock(key);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +81,7 @@ export class Locks<T> {
|
||||||
// Resolve all waiting promises before clearing to prevent deadlock
|
// Resolve all waiting promises before clearing to prevent deadlock
|
||||||
// Any operation waiting for a lock will be granted access immediately
|
// Any operation waiting for a lock will be granted access immediately
|
||||||
for (const waiting of this.waiters.values()) {
|
for (const waiting of this.waiters.values()) {
|
||||||
for (const [_, reject] of waiting) {
|
for (const { reject } of waiting) {
|
||||||
reject(new SyncResetError());
|
reject(new SyncResetError());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +89,10 @@ export class Locks<T> {
|
||||||
this.waiters.clear();
|
this.waiters.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isLocked(key: T): boolean {
|
||||||
|
return this.locked.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to acquire a lock immediately without waiting.
|
* Attempts to acquire a lock immediately without waiting.
|
||||||
* Must call `unlock()` if successful.
|
* Must call `unlock()` if successful.
|
||||||
|
|
@ -111,7 +122,7 @@ export class Locks<T> {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger?.debug(`Waiting for lock on ${key}`);
|
this.logger?.debug(`Waiting for lock '${this.name}' on '${key}'`);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// DefaultDict behavior
|
// DefaultDict behavior
|
||||||
|
|
@ -121,7 +132,10 @@ export class Locks<T> {
|
||||||
this.waiters.set(key, waiting);
|
this.waiters.set(key, waiting);
|
||||||
}
|
}
|
||||||
|
|
||||||
waiting.push([resolve, reject]);
|
waiting.push({
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,15 +148,20 @@ export class Locks<T> {
|
||||||
*/
|
*/
|
||||||
public unlock(key: T): void {
|
public unlock(key: T): void {
|
||||||
if (!this.locked.has(key)) {
|
if (!this.locked.has(key)) {
|
||||||
|
this.logger?.debug(
|
||||||
|
`Attempted to unlock '${this.name}' on '${key}' which is not locked`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove first waiter to ensure FIFO order
|
this.logger?.debug(`Releasing lock '${this.name}' on '${key}'`);
|
||||||
const [resolveNextWaiting, _] = this.waiters.get(key)?.shift() ?? [];
|
|
||||||
|
|
||||||
if (resolveNextWaiting) {
|
// Remove first waiter to ensure FIFO order
|
||||||
this.logger?.debug(`Granted lock on ${key}`);
|
const nextWaiter = this.waiters.get(key)?.shift();
|
||||||
resolveNextWaiting();
|
|
||||||
|
if (nextWaiter) {
|
||||||
|
this.logger?.debug(`Granted lock '${this.name}' on '${key}'`);
|
||||||
|
nextWaiter.resolve();
|
||||||
} else {
|
} else {
|
||||||
this.locked.delete(key);
|
this.locked.delete(key);
|
||||||
}
|
}
|
||||||
|
|
@ -152,8 +171,8 @@ export class Locks<T> {
|
||||||
export class Lock {
|
export class Lock {
|
||||||
private readonly locks: Locks<boolean>;
|
private readonly locks: Locks<boolean>;
|
||||||
|
|
||||||
public constructor(logger?: Logger) {
|
public constructor(name: string, logger?: Logger) {
|
||||||
this.locks = new Locks(logger);
|
this.locks = new Locks(name, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async withLock<R>(fn: () => R | Promise<R>): Promise<R> {
|
public async withLock<R>(fn: () => R | Promise<R>): Promise<R> {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import type { RelativePath } from "../../persistence/database";
|
||||||
|
import type { TextWithCursors } from "reconcile-text";
|
||||||
|
import type { FileSystemOperations } from "../../file-operations/filesystem-operations";
|
||||||
|
|
||||||
|
export class InMemoryFileSystem implements FileSystemOperations {
|
||||||
|
protected readonly files = new Map<string, Uint8Array>();
|
||||||
|
|
||||||
|
public async listFilesRecursively(
|
||||||
|
_root: RelativePath | undefined = undefined // we don't use multi-level paths during tests
|
||||||
|
): Promise<RelativePath[]> {
|
||||||
|
return Array.from(this.files.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||||
|
const file = this.files.get(path);
|
||||||
|
if (!file) {
|
||||||
|
throw new Error(`File ${path} does not exist`);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
||||||
|
this.files.set(path, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async atomicUpdateText(
|
||||||
|
path: RelativePath,
|
||||||
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
|
): Promise<string> {
|
||||||
|
const file = this.files.get(path);
|
||||||
|
if (!file) {
|
||||||
|
throw new Error(`File ${path} does not exist`);
|
||||||
|
}
|
||||||
|
const currentContent = new TextDecoder().decode(file);
|
||||||
|
const newContent = updater({ text: currentContent, cursors: [] }).text;
|
||||||
|
this.files.set(path, new TextEncoder().encode(newContent));
|
||||||
|
return newContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getFileSize(path: RelativePath): Promise<number> {
|
||||||
|
return (await this.read(path)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async exists(path: RelativePath): Promise<boolean> {
|
||||||
|
return this.files.has(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createDirectory(_path: RelativePath): Promise<void> {
|
||||||
|
// This doesn't mean anything in our virtual FS representation
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(path: RelativePath): Promise<void> {
|
||||||
|
this.files.delete(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rename(
|
||||||
|
oldPath: RelativePath,
|
||||||
|
newPath: RelativePath
|
||||||
|
): Promise<void> {
|
||||||
|
const file = this.files.get(oldPath);
|
||||||
|
if (!file) {
|
||||||
|
throw new Error(`File ${oldPath} does not exist`);
|
||||||
|
}
|
||||||
|
this.files.set(newPath, file);
|
||||||
|
if (oldPath !== newPath) {
|
||||||
|
this.files.delete(oldPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,44 @@
|
||||||
import type { SyncClient } from "../../sync-client";
|
/* eslint-disable no-console */
|
||||||
import type { LogLine } from "../../tracing/logger";
|
import type { Logger, LogLine } from "../../tracing/logger";
|
||||||
import { LogLevel } from "../../tracing/logger";
|
import { LogLevel } from "../../tracing/logger";
|
||||||
|
|
||||||
export function logToConsole(client: SyncClient): void {
|
const COLORS = {
|
||||||
client.logger.onLogEmitted.add((logLine: LogLine) => {
|
reset: "\x1b[0m",
|
||||||
const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`;
|
red: "\x1b[31m",
|
||||||
|
yellow: "\x1b[33m",
|
||||||
|
blue: "\x1b[34m",
|
||||||
|
gray: "\x1b[90m"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function logToConsole(
|
||||||
|
logger: Logger,
|
||||||
|
{ useColors = true }: { useColors?: boolean } = {}
|
||||||
|
): void {
|
||||||
|
logger.onLogEmitted.add((logLine: LogLine) => {
|
||||||
|
const timestamp = logLine.timestamp.toISOString();
|
||||||
|
const message = logLine.message;
|
||||||
|
|
||||||
|
let color = "";
|
||||||
|
let reset = "";
|
||||||
|
if (useColors) {
|
||||||
|
reset = COLORS.reset;
|
||||||
|
switch (logLine.level) {
|
||||||
|
case LogLevel.ERROR:
|
||||||
|
color = COLORS.red;
|
||||||
|
break;
|
||||||
|
case LogLevel.WARNING:
|
||||||
|
color = COLORS.yellow;
|
||||||
|
break;
|
||||||
|
case LogLevel.INFO:
|
||||||
|
color = COLORS.blue;
|
||||||
|
break;
|
||||||
|
case LogLevel.DEBUG:
|
||||||
|
color = COLORS.gray;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = `${timestamp} ${color}${logLine.level}${reset} ${message}`;
|
||||||
|
|
||||||
switch (logLine.level) {
|
switch (logLine.level) {
|
||||||
case LogLevel.ERROR:
|
case LogLevel.ERROR:
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export function slowWebSocketFactory(
|
||||||
private static readonly RECEIVE_KEY = "websocket-receive";
|
private static readonly RECEIVE_KEY = "websocket-receive";
|
||||||
private static readonly SEND_KEY = "websocket-send";
|
private static readonly SEND_KEY = "websocket-send";
|
||||||
|
|
||||||
private readonly locks = new Locks(logger);
|
private readonly locks = new Locks(FlakyWebSocket.name, logger);
|
||||||
|
|
||||||
public set onopen(callback: ((event: Event) => void) | null) {
|
public set onopen(callback: ((event: Event) => void) | null) {
|
||||||
super.onopen = async (event: Event): Promise<void> => {
|
super.onopen = async (event: Event): Promise<void> => {
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,6 @@ module.exports = [
|
||||||
type: "umd"
|
type: "umd"
|
||||||
},
|
},
|
||||||
globalObject: "this"
|
globalObject: "this"
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
fallback: {
|
|
||||||
ws: false // Exclude `ws` from the browser bundle
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
merge(common, {
|
merge(common, {
|
||||||
|
|
@ -62,10 +57,6 @@ module.exports = [
|
||||||
path: path.resolve(__dirname, "dist"),
|
path: path.resolve(__dirname, "dist"),
|
||||||
filename: "sync-client.node.js",
|
filename: "sync-client.node.js",
|
||||||
libraryTarget: "commonjs2"
|
libraryTarget: "commonjs2"
|
||||||
},
|
|
||||||
externals: {
|
|
||||||
bufferutil: "bufferutil",
|
|
||||||
"utf-8-validate": "utf-8-validate" // required for ws: https://github.com/websockets/ws/issues/2245#issuecomment-2250318733
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue