diff --git a/frontend/history-ui/src/lib/types/ClientCursors.ts b/frontend/history-ui/src/lib/types/ClientCursors.ts index 14298431..bb629100 100644 --- a/frontend/history-ui/src/lib/types/ClientCursors.ts +++ b/frontend/history-ui/src/lib/types/ClientCursors.ts @@ -1,8 +1,4 @@ // 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"; -export type ClientCursors = { - userName: string; - deviceId: string; - documentsWithCursors: Array; -}; +export type ClientCursors = { userName: string, deviceId: string, documentsWithCursors: Array, }; diff --git a/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts b/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts index 389d8e88..29d3f55e 100644 --- a/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts +++ b/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts @@ -1,7 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CreateDocumentVersion = { - relative_path: string; - last_seen_vault_update_id: number; - content: Array; -}; +export type CreateDocumentVersion = { relative_path: string, last_seen_vault_update_id: number, content: Array, }; diff --git a/frontend/history-ui/src/lib/types/CursorPositionFromClient.ts b/frontend/history-ui/src/lib/types/CursorPositionFromClient.ts index 5846843e..60b48e5e 100644 --- a/frontend/history-ui/src/lib/types/CursorPositionFromClient.ts +++ b/frontend/history-ui/src/lib/types/CursorPositionFromClient.ts @@ -1,6 +1,4 @@ // 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"; -export type CursorPositionFromClient = { - documentsWithCursors: Array; -}; +export type CursorPositionFromClient = { documentsWithCursors: Array, }; diff --git a/frontend/history-ui/src/lib/types/CursorPositionFromServer.ts b/frontend/history-ui/src/lib/types/CursorPositionFromServer.ts index 3a72c706..c8444892 100644 --- a/frontend/history-ui/src/lib/types/CursorPositionFromServer.ts +++ b/frontend/history-ui/src/lib/types/CursorPositionFromServer.ts @@ -1,4 +1,4 @@ // 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"; -export type CursorPositionFromServer = { clients: Array }; +export type CursorPositionFromServer = { clients: Array, }; diff --git a/frontend/history-ui/src/lib/types/CursorSpan.ts b/frontend/history-ui/src/lib/types/CursorSpan.ts index 916019ce..d0bce6ea 100644 --- a/frontend/history-ui/src/lib/types/CursorSpan.ts +++ b/frontend/history-ui/src/lib/types/CursorSpan.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CursorSpan = { start: number; end: number }; +export type CursorSpan = { start: number, end: number, }; diff --git a/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts b/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts index dd7eadda..51e0b37c 100644 --- a/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts +++ b/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts @@ -5,6 +5,4 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont /** * Response to a create/update document request. */ -export type DocumentUpdateResponse = - | ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent) - | ({ type: "MergingUpdate" } & DocumentVersion); +export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion; diff --git a/frontend/history-ui/src/lib/types/DocumentVersion.ts b/frontend/history-ui/src/lib/types/DocumentVersion.ts index 50a6c591..37bd32ca 100644 --- a/frontend/history-ui/src/lib/types/DocumentVersion.ts +++ b/frontend/history-ui/src/lib/types/DocumentVersion.ts @@ -1,12 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DocumentVersion = { - vaultUpdateId: number; - documentId: string; - relativePath: string; - updatedDate: string; - contentBase64: string; - isDeleted: boolean; - userId: string; - deviceId: string; -}; +export type DocumentVersion = { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, }; diff --git a/frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts b/frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts index dad1f135..18d9bca5 100644 --- a/frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts +++ b/frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts @@ -1,12 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DocumentVersionWithoutContent = { - vaultUpdateId: number; - documentId: string; - relativePath: string; - updatedDate: string; - isDeleted: boolean; - userId: string; - deviceId: string; - contentSize: number; -}; +export type DocumentVersionWithoutContent = { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, +/** + * True iff this is the first version of the document + */ +isNewFile: boolean, }; diff --git a/frontend/history-ui/src/lib/types/DocumentWithCursors.ts b/frontend/history-ui/src/lib/types/DocumentWithCursors.ts index ca6a2155..3504ce33 100644 --- a/frontend/history-ui/src/lib/types/DocumentWithCursors.ts +++ b/frontend/history-ui/src/lib/types/DocumentWithCursors.ts @@ -1,9 +1,4 @@ // 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"; -export type DocumentWithCursors = { - vaultUpdateId: number | null; - documentId: string; - relativePath: string; - cursors: Array; -}; +export type DocumentWithCursors = { vaultUpdateId: number | null, documentId: string, relativePath: string, cursors: Array, }; diff --git a/frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.ts b/frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.ts index 141c2565..ce572684 100644 --- a/frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.ts +++ b/frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.ts @@ -4,10 +4,8 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont /** * Response to a fetch latest documents request. */ -export type FetchLatestDocumentsResponse = { - latestDocuments: Array; - /** - * The update ID of the latest document in the response. - */ - lastUpdateId: bigint; -}; +export type FetchLatestDocumentsResponse = { latestDocuments: Array, +/** + * The update ID of the latest document in the response. + */ +lastUpdateId: bigint, }; diff --git a/frontend/history-ui/src/lib/types/ListVaultsResponse.ts b/frontend/history-ui/src/lib/types/ListVaultsResponse.ts index 604ad958..92b2b3e0 100644 --- a/frontend/history-ui/src/lib/types/ListVaultsResponse.ts +++ b/frontend/history-ui/src/lib/types/ListVaultsResponse.ts @@ -4,8 +4,4 @@ import type { VaultInfo } from "./VaultInfo"; /** * Response to listing vaults accessible to the authenticated user. */ -export type ListVaultsResponse = { - vaults: Array; - hasMore: boolean; - userName: string; -}; +export type ListVaultsResponse = { vaults: Array, hasMore: boolean, userName: string, }; diff --git a/frontend/history-ui/src/lib/types/PingResponse.ts b/frontend/history-ui/src/lib/types/PingResponse.ts index 7e5ac4f8..c38845d2 100644 --- a/frontend/history-ui/src/lib/types/PingResponse.ts +++ b/frontend/history-ui/src/lib/types/PingResponse.ts @@ -3,23 +3,22 @@ /** * Response to a ping request. */ -export type PingResponse = { - /** - * Semantic version of the server. - */ - serverVersion: string; - /** - * Whether the client is authenticated based on the sent Authorization - * header. - */ - isAuthenticated: boolean; - /** - * List of file extensions that are allowed to be merged. - */ - mergeableFileExtensions: Array; - /** - * API version ensuring backwards & forwards compatibility between the client - * and server. - */ - supportedApiVersion: number; -}; +export type PingResponse = { +/** + * Semantic version of the server. + */ +serverVersion: string, +/** + * Whether the client is authenticated based on the sent Authorization + * header. + */ +isAuthenticated: boolean, +/** + * List of file extensions that are allowed to be merged. + */ +mergeableFileExtensions: Array, +/** + * API version ensuring backwards & forwards compatibility between the client + * and server. + */ +supportedApiVersion: number, }; diff --git a/frontend/history-ui/src/lib/types/SerializedError.ts b/frontend/history-ui/src/lib/types/SerializedError.ts index 354305f6..5e3fa9b9 100644 --- a/frontend/history-ui/src/lib/types/SerializedError.ts +++ b/frontend/history-ui/src/lib/types/SerializedError.ts @@ -1,7 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type SerializedError = { - errorType: string; - message: string; - causes: Array; -}; +export type SerializedError = { errorType: string, message: string, causes: Array, }; diff --git a/frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts b/frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts index ce0272e3..1c86b132 100644 --- a/frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts +++ b/frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts @@ -1,7 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type UpdateTextDocumentVersion = { - parentVersionId: number; - relativePath: string; - content: Array; -}; +export type UpdateTextDocumentVersion = { parentVersionId: number, relativePath: string | null, content: Array, }; diff --git a/frontend/history-ui/src/lib/types/VaultHistoryResponse.ts b/frontend/history-ui/src/lib/types/VaultHistoryResponse.ts index e69366f0..ae91b480 100644 --- a/frontend/history-ui/src/lib/types/VaultHistoryResponse.ts +++ b/frontend/history-ui/src/lib/types/VaultHistoryResponse.ts @@ -4,7 +4,4 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont /** * Response to a vault history request (paginated). */ -export type VaultHistoryResponse = { - versions: Array; - hasMore: boolean; -}; +export type VaultHistoryResponse = { versions: Array, hasMore: boolean, }; diff --git a/frontend/history-ui/src/lib/types/VaultInfo.ts b/frontend/history-ui/src/lib/types/VaultInfo.ts index 3f630ae9..32373346 100644 --- a/frontend/history-ui/src/lib/types/VaultInfo.ts +++ b/frontend/history-ui/src/lib/types/VaultInfo.ts @@ -3,8 +3,4 @@ /** * Summary of a single vault returned by the list-vaults endpoint. */ -export type VaultInfo = { - name: string; - documentCount: number; - createdAt: string | null; -}; +export type VaultInfo = { name: string, documentCount: number, createdAt: string | null, }; diff --git a/frontend/history-ui/src/lib/types/WebSocketClientMessage.ts b/frontend/history-ui/src/lib/types/WebSocketClientMessage.ts index 9608f3af..5765a0d0 100644 --- a/frontend/history-ui/src/lib/types/WebSocketClientMessage.ts +++ b/frontend/history-ui/src/lib/types/WebSocketClientMessage.ts @@ -2,6 +2,4 @@ import type { CursorPositionFromClient } from "./CursorPositionFromClient"; import type { WebSocketHandshake } from "./WebSocketHandshake"; -export type WebSocketClientMessage = - | ({ type: "handshake" } & WebSocketHandshake) - | ({ type: "cursorPositions" } & CursorPositionFromClient); +export type WebSocketClientMessage = { "type": "handshake" } & WebSocketHandshake | { "type": "cursorPositions" } & CursorPositionFromClient; diff --git a/frontend/history-ui/src/lib/types/WebSocketHandshake.ts b/frontend/history-ui/src/lib/types/WebSocketHandshake.ts index 8e51a121..85c2cf0d 100644 --- a/frontend/history-ui/src/lib/types/WebSocketHandshake.ts +++ b/frontend/history-ui/src/lib/types/WebSocketHandshake.ts @@ -1,7 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type WebSocketHandshake = { - token: string; - deviceId: string; - lastSeenVaultUpdateId: number | null; -}; +export type WebSocketHandshake = { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, }; diff --git a/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts b/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts index fd250b7b..45e37358 100644 --- a/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts +++ b/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts @@ -2,6 +2,4 @@ import type { CursorPositionFromServer } from "./CursorPositionFromServer"; import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; -export type WebSocketServerMessage = - | ({ type: "vaultUpdate" } & WebSocketVaultUpdate) - | ({ type: "cursorPositions" } & CursorPositionFromServer); +export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer; diff --git a/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts b/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts index 94d70c0a..fc10827f 100644 --- a/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts +++ b/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts @@ -1,4 +1,4 @@ // 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"; -export type WebSocketVaultUpdate = { document: DocumentVersionWithoutContent }; +export type WebSocketVaultUpdate = { document: DocumentVersionWithoutContent, }; diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 098e86f4..84826c65 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -16,6 +16,20 @@ export enum MoveOnConflict { NEW = "NEW" } +/** + * Outcome of a `move`/`create`. `actualPath` is where the new file + * ended up (which may differ from the requested path under + * `MoveOnConflict.NEW` if the target was occupied). `displacedTo` is + * set only when an existing file at the requested path was bumped to + * a `conflict-…` path under `MoveOnConflict.EXISTING`; the caller + * uses it to repoint any tracking for the displaced doc before its + * own follow-up `setDocument` clobbers the old slot. + */ +export interface FileOpResult { + actualPath: RelativePath; + displacedTo?: RelativePath; +} + export class FileOperations { private readonly fs: SafeFileSystemOperations; @@ -67,29 +81,27 @@ export class FileOperations { * * If a file with the same name already exists, it is moved before creating the new one. * Parent directories are created if necessary. - * - * Returns the actual path the file was created at. */ public async create( path: RelativePath, newContent: Uint8Array, moveOnConflict: MoveOnConflict - ): Promise { - const actualPath = await this.ensureClearPath(path, moveOnConflict); + ): Promise { + const result = await this.ensureClearPath(path, moveOnConflict); // ensureClearPath leaves actualPath empty: either the file never // existed, or it was just renamed away. The upcoming write therefore // looks like a fresh create to the watcher. - this.expectedFsEvents.expectCreate(actualPath); + this.expectedFsEvents.expectCreate(result.actualPath); try { await this.fs.write( - actualPath, + result.actualPath, this.toNativeLineEndings(newContent) ); } catch (e) { - this.expectedFsEvents.unexpectCreate(actualPath); + this.expectedFsEvents.unexpectCreate(result.actualPath); throw e; } - return actualPath; + return result; } /** @@ -215,37 +227,36 @@ export class FileOperations { - // Returns the actual path the file got moved to. public async move( oldPath: RelativePath, newPath: RelativePath, moveOnConflict: MoveOnConflict - ): Promise { + ): Promise { if (oldPath === newPath) { - return oldPath; + return { actualPath: oldPath }; } - const actualPath = await this.ensureClearPath(newPath, moveOnConflict); - this.expectedFsEvents.expectRename(oldPath, actualPath); + const cleared = await this.ensureClearPath(newPath, moveOnConflict); + this.expectedFsEvents.expectRename(oldPath, cleared.actualPath); try { - await this.fs.rename(oldPath, actualPath); + await this.fs.rename(oldPath, cleared.actualPath); } catch (e) { - this.expectedFsEvents.unexpectRename(oldPath, actualPath); + this.expectedFsEvents.unexpectRename(oldPath, cleared.actualPath); throw e; } await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); - return actualPath; + return cleared; } private async ensureClearPath( path: RelativePath, moveOnConflict: MoveOnConflict - ): Promise { + ): Promise { if (await this.fs.exists(path)) { const conflictPath = FileOperations.buildConflictPath(path); if (moveOnConflict === MoveOnConflict.NEW) { - return conflictPath; + return { actualPath: conflictPath }; } this.logger.debug( @@ -266,7 +277,7 @@ export class FileOperations { `No existing file at ${path}, creating parent directories if needed` ); await this.createParentDirectories(path); - return path; + return { actualPath: path }; } private async deletingEmptyParentDirectoriesOfDeletedFile( diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts index 40afc709..47aea2f4 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -71,7 +71,7 @@ export class SyncService { response: Response, operation: string ): Promise { - if (response.ok) {return;} + if (response.ok) { return; } const message = `Failed to ${operation}: ${await SyncService.errorFromResponse(response)}`; // 429 is the only 4xx the server uses for *transient* contention // (`WriteBusyError` → HTTP 429). Every other 4xx means the request @@ -154,17 +154,17 @@ export class SyncService { }: { parentVersionId: VaultUpdateId; documentId: DocumentId; - relativePath: RelativePath; + relativePath: RelativePath | undefined; content: (number | string)[]; }): Promise { return this.retryForever(async () => { this.logger.debug( - `Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}, content [${content.join(", ")}]` + `Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath ?? ""}, content [${content.join(", ")}]` ); const request: UpdateTextDocumentVersion = { parentVersionId, - relativePath, + relativePath: relativePath ?? null, content }; @@ -199,16 +199,18 @@ export class SyncService { }: { parentVersionId: VaultUpdateId; documentId: DocumentId; - relativePath: RelativePath; + relativePath: RelativePath | undefined; contentBytes: Uint8Array; }): Promise { return this.retryForever(async () => { this.logger.debug( - `Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}` + `Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath ?? ""}` ); const formData = new FormData(); formData.append("parent_version_id", parentVersionId.toString()); - formData.append("relative_path", relativePath); + if (relativePath !== undefined) { + formData.append("relative_path", relativePath); + } formData.append( "content", new Blob([new Uint8Array(contentBytes)]) @@ -239,14 +241,12 @@ export class SyncService { public async delete({ documentId, - relativePath }: { documentId: DocumentId; - relativePath: RelativePath; }): Promise { return this.retryForever(async () => { this.logger.debug( - `Delete document with id ${documentId} and relative path ${relativePath}` + `Delete document with id ${documentId}` ); // The server identifies the document by its URL path; no body @@ -265,7 +265,7 @@ export class SyncService { (await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion this.logger.debug( - `Deleted document ${relativePath} with id ${documentId}` + `Deleted document with id ${documentId}` ); return result; diff --git a/frontend/sync-client/src/services/types/ClientCursors.ts b/frontend/sync-client/src/services/types/ClientCursors.ts index e8c9b93d..5b1ec040 100644 --- a/frontend/sync-client/src/services/types/ClientCursors.ts +++ b/frontend/sync-client/src/services/types/ClientCursors.ts @@ -1,8 +1,4 @@ // 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"; -export interface ClientCursors { - userName: string; - deviceId: string; - documentsWithCursors: DocumentWithCursors[]; -} +export interface ClientCursors { userName: string, deviceId: string, documentsWithCursors: DocumentWithCursors[], } diff --git a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts index 2d83cd99..4d1b324e 100644 --- a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts @@ -1,7 +1,3 @@ // 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; - last_seen_vault_update_id: number; - content: number[]; -} +export interface CreateDocumentVersion { relative_path: string, last_seen_vault_update_id: number, content: number[], } diff --git a/frontend/sync-client/src/services/types/CursorPositionFromClient.ts b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts index ee937f4e..78823b5d 100644 --- a/frontend/sync-client/src/services/types/CursorPositionFromClient.ts +++ b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts @@ -1,6 +1,4 @@ // 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"; -export interface CursorPositionFromClient { - documentsWithCursors: DocumentWithCursors[]; -} +export interface CursorPositionFromClient { documentsWithCursors: DocumentWithCursors[], } diff --git a/frontend/sync-client/src/services/types/CursorPositionFromServer.ts b/frontend/sync-client/src/services/types/CursorPositionFromServer.ts index 52a24f27..ed6ac7b2 100644 --- a/frontend/sync-client/src/services/types/CursorPositionFromServer.ts +++ b/frontend/sync-client/src/services/types/CursorPositionFromServer.ts @@ -1,6 +1,4 @@ // 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"; -export interface CursorPositionFromServer { - clients: ClientCursors[]; -} +export interface CursorPositionFromServer { clients: ClientCursors[], } diff --git a/frontend/sync-client/src/services/types/CursorSpan.ts b/frontend/sync-client/src/services/types/CursorSpan.ts index 2cc2b7fc..7424067c 100644 --- a/frontend/sync-client/src/services/types/CursorSpan.ts +++ b/frontend/sync-client/src/services/types/CursorSpan.ts @@ -1,6 +1,3 @@ // 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, } diff --git a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts index dd7eadda..51e0b37c 100644 --- a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts +++ b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts @@ -5,6 +5,4 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont /** * Response to a create/update document request. */ -export type DocumentUpdateResponse = - | ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent) - | ({ type: "MergingUpdate" } & DocumentVersion); +export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion; diff --git a/frontend/sync-client/src/services/types/DocumentVersion.ts b/frontend/sync-client/src/services/types/DocumentVersion.ts index 3b9aa37b..3d50ae65 100644 --- a/frontend/sync-client/src/services/types/DocumentVersion.ts +++ b/frontend/sync-client/src/services/types/DocumentVersion.ts @@ -1,12 +1,3 @@ // 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, } diff --git a/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts index 4b24e7c5..c243e639 100644 --- a/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts +++ b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts @@ -1,12 +1,7 @@ // 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, +/** + * True iff this is the first version of the document + */ +isNewFile: boolean, } diff --git a/frontend/sync-client/src/services/types/DocumentWithCursors.ts b/frontend/sync-client/src/services/types/DocumentWithCursors.ts index 8ed59067..d29b3f79 100644 --- a/frontend/sync-client/src/services/types/DocumentWithCursors.ts +++ b/frontend/sync-client/src/services/types/DocumentWithCursors.ts @@ -1,9 +1,4 @@ // 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"; -export interface DocumentWithCursors { - vaultUpdateId: number | null; - documentId: string; - relativePath: string; - cursors: CursorSpan[]; -} +export interface DocumentWithCursors { vaultUpdateId: number | null, documentId: string, relativePath: string, cursors: CursorSpan[], } diff --git a/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts index 315d701a..3be625bd 100644 --- a/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts +++ b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts @@ -4,10 +4,8 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont /** * Response to a fetch latest documents request. */ -export interface FetchLatestDocumentsResponse { - latestDocuments: DocumentVersionWithoutContent[]; - /** - * The update ID of the latest document in the response. - */ - lastUpdateId: bigint; -} +export interface FetchLatestDocumentsResponse { latestDocuments: DocumentVersionWithoutContent[], +/** + * The update ID of the latest document in the response. + */ +lastUpdateId: bigint, } diff --git a/frontend/sync-client/src/services/types/ListVaultsResponse.ts b/frontend/sync-client/src/services/types/ListVaultsResponse.ts index babad2d5..85928d89 100644 --- a/frontend/sync-client/src/services/types/ListVaultsResponse.ts +++ b/frontend/sync-client/src/services/types/ListVaultsResponse.ts @@ -4,8 +4,4 @@ import type { VaultInfo } from "./VaultInfo"; /** * Response to listing vaults accessible to the authenticated user. */ -export interface ListVaultsResponse { - vaults: VaultInfo[]; - hasMore: boolean; - userName: string; -} +export interface ListVaultsResponse { vaults: VaultInfo[], hasMore: boolean, userName: string, } diff --git a/frontend/sync-client/src/services/types/PingResponse.ts b/frontend/sync-client/src/services/types/PingResponse.ts index f96520e9..ba8ceb48 100644 --- a/frontend/sync-client/src/services/types/PingResponse.ts +++ b/frontend/sync-client/src/services/types/PingResponse.ts @@ -3,23 +3,22 @@ /** * Response to a ping request. */ -export interface PingResponse { - /** - * Semantic version of the server. - */ - serverVersion: string; - /** - * Whether the client is authenticated based on the sent Authorization - * header. - */ - isAuthenticated: boolean; - /** - * List of file extensions that are allowed to be merged. - */ - mergeableFileExtensions: string[]; - /** - * API version ensuring backwards & forwards compatibility between the client - * and server. - */ - supportedApiVersion: number; -} +export interface PingResponse { +/** + * Semantic version of the server. + */ +serverVersion: string, +/** + * Whether the client is authenticated based on the sent Authorization + * header. + */ +isAuthenticated: boolean, +/** + * List of file extensions that are allowed to be merged. + */ +mergeableFileExtensions: string[], +/** + * API version ensuring backwards & forwards compatibility between the client + * and server. + */ +supportedApiVersion: number, } diff --git a/frontend/sync-client/src/services/types/SerializedError.ts b/frontend/sync-client/src/services/types/SerializedError.ts index ec1c4503..4389289e 100644 --- a/frontend/sync-client/src/services/types/SerializedError.ts +++ b/frontend/sync-client/src/services/types/SerializedError.ts @@ -1,7 +1,3 @@ // 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[], } diff --git a/frontend/sync-client/src/services/types/UpdateTextDocumentVersion.ts b/frontend/sync-client/src/services/types/UpdateTextDocumentVersion.ts index 46f36bd0..2fb04b31 100644 --- a/frontend/sync-client/src/services/types/UpdateTextDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/UpdateTextDocumentVersion.ts @@ -1,7 +1,3 @@ // 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 | null, content: (number | string)[], } diff --git a/frontend/sync-client/src/services/types/VaultHistoryResponse.ts b/frontend/sync-client/src/services/types/VaultHistoryResponse.ts index 35531010..93d6ec6c 100644 --- a/frontend/sync-client/src/services/types/VaultHistoryResponse.ts +++ b/frontend/sync-client/src/services/types/VaultHistoryResponse.ts @@ -4,7 +4,4 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont /** * Response to a vault history request (paginated). */ -export interface VaultHistoryResponse { - versions: DocumentVersionWithoutContent[]; - hasMore: boolean; -} +export interface VaultHistoryResponse { versions: DocumentVersionWithoutContent[], hasMore: boolean, } diff --git a/frontend/sync-client/src/services/types/VaultInfo.ts b/frontend/sync-client/src/services/types/VaultInfo.ts index 20d6811c..921645f3 100644 --- a/frontend/sync-client/src/services/types/VaultInfo.ts +++ b/frontend/sync-client/src/services/types/VaultInfo.ts @@ -3,8 +3,4 @@ /** * Summary of a single vault returned by the list-vaults endpoint. */ -export interface VaultInfo { - name: string; - documentCount: number; - createdAt: string | null; -} +export interface VaultInfo { name: string, documentCount: number, createdAt: string | null, } diff --git a/frontend/sync-client/src/services/types/WebSocketClientMessage.ts b/frontend/sync-client/src/services/types/WebSocketClientMessage.ts index 9608f3af..5765a0d0 100644 --- a/frontend/sync-client/src/services/types/WebSocketClientMessage.ts +++ b/frontend/sync-client/src/services/types/WebSocketClientMessage.ts @@ -2,6 +2,4 @@ import type { CursorPositionFromClient } from "./CursorPositionFromClient"; import type { WebSocketHandshake } from "./WebSocketHandshake"; -export type WebSocketClientMessage = - | ({ type: "handshake" } & WebSocketHandshake) - | ({ type: "cursorPositions" } & CursorPositionFromClient); +export type WebSocketClientMessage = { "type": "handshake" } & WebSocketHandshake | { "type": "cursorPositions" } & CursorPositionFromClient; diff --git a/frontend/sync-client/src/services/types/WebSocketHandshake.ts b/frontend/sync-client/src/services/types/WebSocketHandshake.ts index a2910f49..d25651f9 100644 --- a/frontend/sync-client/src/services/types/WebSocketHandshake.ts +++ b/frontend/sync-client/src/services/types/WebSocketHandshake.ts @@ -1,7 +1,3 @@ // 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, } diff --git a/frontend/sync-client/src/services/types/WebSocketServerMessage.ts b/frontend/sync-client/src/services/types/WebSocketServerMessage.ts index fd250b7b..45e37358 100644 --- a/frontend/sync-client/src/services/types/WebSocketServerMessage.ts +++ b/frontend/sync-client/src/services/types/WebSocketServerMessage.ts @@ -2,6 +2,4 @@ import type { CursorPositionFromServer } from "./CursorPositionFromServer"; import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; -export type WebSocketServerMessage = - | ({ type: "vaultUpdate" } & WebSocketVaultUpdate) - | ({ type: "cursorPositions" } & CursorPositionFromServer); +export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer; diff --git a/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts index b4a942c8..5e7df8a5 100644 --- a/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts +++ b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts @@ -1,6 +1,4 @@ // 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"; -export interface WebSocketVaultUpdate { - document: DocumentVersionWithoutContent; -} +export interface WebSocketVaultUpdate { document: DocumentVersionWithoutContent, } diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts index c1dbce9e..30692681 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts @@ -30,6 +30,7 @@ function fakeRemoteVersion( userId: "user", deviceId: "device", contentSize: 100, + isNewFile: true, ...overrides }; } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index c57c6135..9ccc76c8 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -446,19 +446,8 @@ export class Syncer { ): Promise { const documentId = await event.documentId; - const doc = this.queue.getDocumentByDocumentId(documentId); - if (doc === undefined) { - // Already deleted (e.g. a remote delete drained ahead of - // this redundant local one). Nothing to do. - this.logger.debug( - `Skipping local-delete for ${documentId} — doc no longer tracked` - ); - return; - } - const response = await this.syncService.delete({ documentId, - relativePath: doc.path }); // Don't remove the doc from the queue or advance lastSeenUpdateId @@ -471,7 +460,7 @@ export class Syncer { status: SyncStatus.SUCCESS, details: { type: SyncType.DELETE, - relativePath: doc.path + relativePath: event.path }, message: "Successfully deleted file on the server", author: response.userId, @@ -499,8 +488,21 @@ export class Syncer { const contentBytes = await this.operations.read(diskPath); const contentHash = await hash(contentBytes); + // For a user-driven rename the user's intent is `event.originalPath` + // — that's the rename target. For a content-only edit the user is + // agnostic to the path; sending one would be wrong if a remote + // rename processed first, because the server would interpret the + // user's (now-stale) path as a rename back. So content-only PUTs + // omit the path and the server keeps the doc at its current + // server-known location. + const renameTarget = event.isUserRename + ? event.originalPath + : undefined; + const hashChanged = contentHash !== record.remoteHash; - const pathChanged = record.remoteRelativePath !== event.originalPath; + const pathChanged = + renameTarget !== undefined && + record.remoteRelativePath !== renameTarget; if (!hashChanged && !pathChanged) { this.logger.debug( @@ -511,12 +513,16 @@ export class Syncer { const response = await this.sendUpdate({ record, - relativePath: event.originalPath, + relativePath: renameTarget, contentBytes }); if (response.isDeleted) { - await this.processRemoteDelete(diskPath, { ...response, contentSize: 0 }); + await this.processRemoteDelete(diskPath, { + ...response, + contentSize: 0, + isNewFile: false + }); return; } @@ -716,6 +722,14 @@ export class Syncer { ); } + if (!remoteVersion.isNewFile) { + this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; + this.logger.debug( + `Ignoring stale RemoteChange for untracked, non-new document ${remoteVersion.documentId}` + ); + return; + } + return this.processRemoteCreateForNewDocument(remoteVersion); } @@ -889,13 +903,15 @@ export class Syncer { contentBytes }: { record: DocumentRecord; - relativePath: RelativePath; + // `undefined` for content-only edits; the server keeps the doc's + // current path. A string is sent only on a user-driven rename. + relativePath: RelativePath | undefined; contentBytes: Uint8Array; }): Promise { const isText = !isBinary(contentBytes) && isFileTypeMergable( - relativePath, + relativePath ?? record.remoteRelativePath, (await this.serverConfig.getConfig()).mergeableFileExtensions ); diff --git a/frontend/sync-client/src/sync-operations/types.ts b/frontend/sync-client/src/sync-operations/types.ts index 7a15aedd..80c74238 100644 --- a/frontend/sync-client/src/sync-operations/types.ts +++ b/frontend/sync-client/src/sync-operations/types.ts @@ -35,35 +35,36 @@ export enum SyncEventType { export type FileSyncEvent = | { type: SyncEventType.LocalCreate; path: RelativePath } | { - type: SyncEventType.LocalUpdate; - path: RelativePath; - oldPath?: RelativePath; // oldPath is undefined for content changes - } + type: SyncEventType.LocalUpdate; + path: RelativePath; + oldPath?: RelativePath; // oldPath is undefined for content changes + } | { type: SyncEventType.LocalDelete; path: RelativePath } | { - type: SyncEventType.RemoteChange; - remoteVersion: DocumentVersionWithoutContent; - }; + type: SyncEventType.RemoteChange; + remoteVersion: DocumentVersionWithoutContent; + }; export type SyncEvent = | { - type: SyncEventType.LocalCreate; - path: RelativePath; // current path on disk - originalPath: RelativePath; // original path on disk when the event was queued - resolvers: PromiseWithResolvers; - } + type: SyncEventType.LocalCreate; + path: RelativePath; // current path on disk + originalPath: RelativePath; // original path on disk when the event was queued + resolvers: PromiseWithResolvers; + } | { - type: SyncEventType.LocalUpdate; - documentId: DocumentId | Promise; // if it's a promise, the promise is fulfilled once the document's create event is processed - path: RelativePath; // current path on disk - originalPath: RelativePath; // original path on disk when the event was queued - // no need to store the old path in case of a rename; the server will figure it out from the parent's path - } + type: SyncEventType.LocalUpdate; + documentId: DocumentId | Promise; // if it's a promise, the promise is fulfilled once the document's create event is processed + path: RelativePath; // current path on disk + originalPath: RelativePath; // original path on disk when the event was queued + isUserRename: boolean; // true iff this event was queued because the user renamed the file + } | { - type: SyncEventType.LocalDelete; - documentId: DocumentId | Promise; // if it's a promise, the promise is fulfilled once the document's create event is processed - } + type: SyncEventType.LocalDelete; + documentId: DocumentId | Promise; // if it's a promise, the promise is fulfilled once the document's create event is processed + path: RelativePath; // only used for showing on the UI + } | { - type: SyncEventType.RemoteChange; - remoteVersion: DocumentVersionWithoutContent; - }; + type: SyncEventType.RemoteChange; + remoteVersion: DocumentVersionWithoutContent; + };