Simplify syncing logic
This commit is contained in:
parent
e8c57b3a37
commit
4493365076
48 changed files with 1024 additions and 888 deletions
|
|
@ -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[];
|
||||||
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue