This commit is contained in:
Andras Schmelczer 2026-04-27 22:50:01 +01:00
parent cc44b66fcd
commit 1163da826e
45 changed files with 192 additions and 292 deletions

View file

@ -1,8 +1,4 @@
// 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 type ClientCursors = { export type ClientCursors = { userName: string, deviceId: string, documentsWithCursors: Array<DocumentWithCursors>, };
userName: string;
deviceId: string;
documentsWithCursors: Array<DocumentWithCursors>;
};

View file

@ -1,7 +1,3 @@
// 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 CreateDocumentVersion = { export type CreateDocumentVersion = { relative_path: string, last_seen_vault_update_id: number, content: Array<number>, };
relative_path: string;
last_seen_vault_update_id: number;
content: Array<number>;
};

View file

@ -1,6 +1,4 @@
// 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 type CursorPositionFromClient = { export type CursorPositionFromClient = { documentsWithCursors: Array<DocumentWithCursors>, };
documentsWithCursors: Array<DocumentWithCursors>;
};

View file

@ -1,4 +1,4 @@
// 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 type CursorPositionFromServer = { clients: Array<ClientCursors> }; export type CursorPositionFromServer = { clients: Array<ClientCursors>, };

View file

@ -1,3 +1,3 @@
// 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 CursorSpan = { start: number; end: number }; export type CursorSpan = { start: number, end: number, };

View file

@ -5,6 +5,4 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
/** /**
* Response to a create/update document request. * Response to a create/update document request.
*/ */
export type DocumentUpdateResponse = export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion;
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
| ({ type: "MergingUpdate" } & DocumentVersion);

View file

@ -1,12 +1,3 @@
// 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 DocumentVersion = { export type DocumentVersion = { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, };
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
contentBase64: string;
isDeleted: boolean;
userId: string;
deviceId: string;
};

View file

@ -1,12 +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 type DocumentVersionWithoutContent = { export type DocumentVersionWithoutContent = { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number,
vaultUpdateId: number; /**
documentId: string; * True iff this is the first version of the document
relativePath: string; */
updatedDate: string; isNewFile: boolean, };
isDeleted: boolean;
userId: string;
deviceId: string;
contentSize: number;
};

View file

@ -1,9 +1,4 @@
// 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 type DocumentWithCursors = { export type DocumentWithCursors = { vaultUpdateId: number | null, documentId: string, relativePath: string, cursors: Array<CursorSpan>, };
vaultUpdateId: number | null;
documentId: string;
relativePath: string;
cursors: Array<CursorSpan>;
};

View file

@ -4,10 +4,8 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
/** /**
* Response to a fetch latest documents request. * Response to a fetch latest documents request.
*/ */
export type FetchLatestDocumentsResponse = { export type FetchLatestDocumentsResponse = { latestDocuments: Array<DocumentVersionWithoutContent>,
latestDocuments: Array<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;
};

View file

@ -4,8 +4,4 @@ import type { VaultInfo } from "./VaultInfo";
/** /**
* Response to listing vaults accessible to the authenticated user. * Response to listing vaults accessible to the authenticated user.
*/ */
export type ListVaultsResponse = { export type ListVaultsResponse = { vaults: Array<VaultInfo>, hasMore: boolean, userName: string, };
vaults: Array<VaultInfo>;
hasMore: boolean;
userName: string;
};

View file

@ -3,23 +3,22 @@
/** /**
* Response to a ping request. * Response to a ping request.
*/ */
export type PingResponse = { export type 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: Array<string>; mergeableFileExtensions: Array<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, };
};

View file

@ -1,7 +1,3 @@
// 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 SerializedError = { export type SerializedError = { errorType: string, message: string, causes: Array<string>, };
errorType: string;
message: string;
causes: Array<string>;
};

View file

@ -1,7 +1,3 @@
// 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 UpdateTextDocumentVersion = { export type UpdateTextDocumentVersion = { parentVersionId: number, relativePath: string | null, content: Array<number | string>, };
parentVersionId: number;
relativePath: string;
content: Array<number | string>;
};

View file

@ -4,7 +4,4 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
/** /**
* Response to a vault history request (paginated). * Response to a vault history request (paginated).
*/ */
export type VaultHistoryResponse = { export type VaultHistoryResponse = { versions: Array<DocumentVersionWithoutContent>, hasMore: boolean, };
versions: Array<DocumentVersionWithoutContent>;
hasMore: boolean;
};

View file

@ -3,8 +3,4 @@
/** /**
* Summary of a single vault returned by the list-vaults endpoint. * Summary of a single vault returned by the list-vaults endpoint.
*/ */
export type VaultInfo = { export type VaultInfo = { name: string, documentCount: number, createdAt: string | null, };
name: string;
documentCount: number;
createdAt: string | null;
};

View file

@ -2,6 +2,4 @@
import type { CursorPositionFromClient } from "./CursorPositionFromClient"; import type { CursorPositionFromClient } from "./CursorPositionFromClient";
import type { WebSocketHandshake } from "./WebSocketHandshake"; import type { WebSocketHandshake } from "./WebSocketHandshake";
export type WebSocketClientMessage = export type WebSocketClientMessage = { "type": "handshake" } & WebSocketHandshake | { "type": "cursorPositions" } & CursorPositionFromClient;
| ({ type: "handshake" } & WebSocketHandshake)
| ({ type: "cursorPositions" } & CursorPositionFromClient);

View file

@ -1,7 +1,3 @@
// 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 WebSocketHandshake = { export type WebSocketHandshake = { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, };
token: string;
deviceId: string;
lastSeenVaultUpdateId: number | null;
};

View file

@ -2,6 +2,4 @@
import type { CursorPositionFromServer } from "./CursorPositionFromServer"; import type { CursorPositionFromServer } from "./CursorPositionFromServer";
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
export type WebSocketServerMessage = export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer;
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
| ({ type: "cursorPositions" } & CursorPositionFromServer);

View file

@ -1,4 +1,4 @@
// 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 type WebSocketVaultUpdate = { document: DocumentVersionWithoutContent }; export type WebSocketVaultUpdate = { document: DocumentVersionWithoutContent, };

View file

@ -16,6 +16,20 @@ export enum MoveOnConflict {
NEW = "NEW" 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 { export class FileOperations {
private readonly fs: SafeFileSystemOperations; 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. * If a file with the same name already exists, it is moved before creating the new one.
* Parent directories are created if necessary. * Parent directories are created if necessary.
*
* Returns the actual path the file was created at.
*/ */
public async create( public async create(
path: RelativePath, path: RelativePath,
newContent: Uint8Array, newContent: Uint8Array,
moveOnConflict: MoveOnConflict moveOnConflict: MoveOnConflict
): Promise<RelativePath> { ): Promise<FileOpResult> {
const actualPath = await this.ensureClearPath(path, moveOnConflict); const result = await this.ensureClearPath(path, moveOnConflict);
// ensureClearPath leaves actualPath empty: either the file never // ensureClearPath leaves actualPath empty: either the file never
// existed, or it was just renamed away. The upcoming write therefore // existed, or it was just renamed away. The upcoming write therefore
// looks like a fresh create to the watcher. // looks like a fresh create to the watcher.
this.expectedFsEvents.expectCreate(actualPath); this.expectedFsEvents.expectCreate(result.actualPath);
try { try {
await this.fs.write( await this.fs.write(
actualPath, result.actualPath,
this.toNativeLineEndings(newContent) this.toNativeLineEndings(newContent)
); );
} catch (e) { } catch (e) {
this.expectedFsEvents.unexpectCreate(actualPath); this.expectedFsEvents.unexpectCreate(result.actualPath);
throw e; 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( public async move(
oldPath: RelativePath, oldPath: RelativePath,
newPath: RelativePath, newPath: RelativePath,
moveOnConflict: MoveOnConflict moveOnConflict: MoveOnConflict
): Promise<RelativePath> { ): Promise<FileOpResult> {
if (oldPath === newPath) { if (oldPath === newPath) {
return oldPath; return { actualPath: oldPath };
} }
const actualPath = await this.ensureClearPath(newPath, moveOnConflict); const cleared = await this.ensureClearPath(newPath, moveOnConflict);
this.expectedFsEvents.expectRename(oldPath, actualPath); this.expectedFsEvents.expectRename(oldPath, cleared.actualPath);
try { try {
await this.fs.rename(oldPath, actualPath); await this.fs.rename(oldPath, cleared.actualPath);
} catch (e) { } catch (e) {
this.expectedFsEvents.unexpectRename(oldPath, actualPath); this.expectedFsEvents.unexpectRename(oldPath, cleared.actualPath);
throw e; throw e;
} }
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
return actualPath; return cleared;
} }
private async ensureClearPath( private async ensureClearPath(
path: RelativePath, path: RelativePath,
moveOnConflict: MoveOnConflict moveOnConflict: MoveOnConflict
): Promise<RelativePath> { ): Promise<FileOpResult> {
if (await this.fs.exists(path)) { if (await this.fs.exists(path)) {
const conflictPath = FileOperations.buildConflictPath(path); const conflictPath = FileOperations.buildConflictPath(path);
if (moveOnConflict === MoveOnConflict.NEW) { if (moveOnConflict === MoveOnConflict.NEW) {
return conflictPath; return { actualPath: conflictPath };
} }
this.logger.debug( this.logger.debug(
@ -266,7 +277,7 @@ export class FileOperations {
`No existing file at ${path}, creating parent directories if needed` `No existing file at ${path}, creating parent directories if needed`
); );
await this.createParentDirectories(path); await this.createParentDirectories(path);
return path; return { actualPath: path };
} }
private async deletingEmptyParentDirectoriesOfDeletedFile( private async deletingEmptyParentDirectoriesOfDeletedFile(

View file

@ -71,7 +71,7 @@ export class SyncService {
response: Response, response: Response,
operation: string operation: string
): Promise<void> { ): Promise<void> {
if (response.ok) {return;} if (response.ok) { return; }
const message = `Failed to ${operation}: ${await SyncService.errorFromResponse(response)}`; const message = `Failed to ${operation}: ${await SyncService.errorFromResponse(response)}`;
// 429 is the only 4xx the server uses for *transient* contention // 429 is the only 4xx the server uses for *transient* contention
// (`WriteBusyError` → HTTP 429). Every other 4xx means the request // (`WriteBusyError` → HTTP 429). Every other 4xx means the request
@ -154,17 +154,17 @@ export class SyncService {
}: { }: {
parentVersionId: VaultUpdateId; parentVersionId: VaultUpdateId;
documentId: DocumentId; documentId: DocumentId;
relativePath: RelativePath; relativePath: RelativePath | undefined;
content: (number | string)[]; content: (number | string)[];
}): Promise<DocumentUpdateResponse> { }): Promise<DocumentUpdateResponse> {
return this.retryForever(async () => { return this.retryForever(async () => {
this.logger.debug( 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 ?? "<unchanged>"}, content [${content.join(", ")}]`
); );
const request: UpdateTextDocumentVersion = { const request: UpdateTextDocumentVersion = {
parentVersionId, parentVersionId,
relativePath, relativePath: relativePath ?? null,
content content
}; };
@ -199,16 +199,18 @@ export class SyncService {
}: { }: {
parentVersionId: VaultUpdateId; parentVersionId: VaultUpdateId;
documentId: DocumentId; documentId: DocumentId;
relativePath: RelativePath; relativePath: RelativePath | undefined;
contentBytes: Uint8Array; contentBytes: Uint8Array;
}): Promise<DocumentUpdateResponse> { }): Promise<DocumentUpdateResponse> {
return this.retryForever(async () => { return this.retryForever(async () => {
this.logger.debug( 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 ?? "<unchanged>"}`
); );
const formData = new FormData(); const formData = new FormData();
formData.append("parent_version_id", parentVersionId.toString()); formData.append("parent_version_id", parentVersionId.toString());
formData.append("relative_path", relativePath); if (relativePath !== undefined) {
formData.append("relative_path", relativePath);
}
formData.append( formData.append(
"content", "content",
new Blob([new Uint8Array(contentBytes)]) new Blob([new Uint8Array(contentBytes)])
@ -239,14 +241,12 @@ export class SyncService {
public async delete({ public async delete({
documentId, documentId,
relativePath
}: { }: {
documentId: DocumentId; documentId: DocumentId;
relativePath: RelativePath;
}): Promise<DocumentVersionWithoutContent> { }): Promise<DocumentVersionWithoutContent> {
return this.retryForever(async () => { return this.retryForever(async () => {
this.logger.debug( 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 // 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 (await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug( this.logger.debug(
`Deleted document ${relativePath} with id ${documentId}` `Deleted document with id ${documentId}`
); );
return result; return result;

View file

@ -1,8 +1,4 @@
// 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 { export interface ClientCursors { userName: string, deviceId: string, documentsWithCursors: DocumentWithCursors[], }
userName: string;
deviceId: string;
documentsWithCursors: DocumentWithCursors[];
}

View file

@ -1,7 +1,3 @@
// 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 { export interface CreateDocumentVersion { relative_path: string, last_seen_vault_update_id: number, content: number[], }
relative_path: string;
last_seen_vault_update_id: number;
content: number[];
}

View file

@ -1,6 +1,4 @@
// 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 { export interface CursorPositionFromClient { documentsWithCursors: DocumentWithCursors[], }
documentsWithCursors: DocumentWithCursors[];
}

View file

@ -1,6 +1,4 @@
// 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 { export interface CursorPositionFromServer { clients: ClientCursors[], }
clients: ClientCursors[];
}

View file

@ -1,6 +1,3 @@
// 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 { export interface CursorSpan { start: number, end: number, }
start: number;
end: number;
}

View file

@ -5,6 +5,4 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
/** /**
* Response to a create/update document request. * Response to a create/update document request.
*/ */
export type DocumentUpdateResponse = export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion;
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
| ({ type: "MergingUpdate" } & DocumentVersion);

View file

@ -1,12 +1,3 @@
// 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 { export interface DocumentVersion { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, }
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
contentBase64: string;
isDeleted: boolean;
userId: string;
deviceId: string;
}

View file

@ -1,12 +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 DocumentVersionWithoutContent { export interface DocumentVersionWithoutContent { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number,
vaultUpdateId: number; /**
documentId: string; * True iff this is the first version of the document
relativePath: string; */
updatedDate: string; isNewFile: boolean, }
isDeleted: boolean;
userId: string;
deviceId: string;
contentSize: number;
}

View file

@ -1,9 +1,4 @@
// 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 { export interface DocumentWithCursors { vaultUpdateId: number | null, documentId: string, relativePath: string, cursors: CursorSpan[], }
vaultUpdateId: number | null;
documentId: string;
relativePath: string;
cursors: CursorSpan[];
}

View file

@ -4,10 +4,8 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
/** /**
* Response to a fetch latest documents request. * Response to a fetch latest documents request.
*/ */
export interface FetchLatestDocumentsResponse { export interface FetchLatestDocumentsResponse { latestDocuments: DocumentVersionWithoutContent[],
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;
}

View file

@ -4,8 +4,4 @@ import type { VaultInfo } from "./VaultInfo";
/** /**
* Response to listing vaults accessible to the authenticated user. * Response to listing vaults accessible to the authenticated user.
*/ */
export interface ListVaultsResponse { export interface ListVaultsResponse { vaults: VaultInfo[], hasMore: boolean, userName: string, }
vaults: VaultInfo[];
hasMore: boolean;
userName: string;
}

View file

@ -3,23 +3,22 @@
/** /**
* Response to a ping request. * Response to a ping request.
*/ */
export interface PingResponse { 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, }
}

View file

@ -1,7 +1,3 @@
// 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 { export interface SerializedError { errorType: string, message: string, causes: string[], }
errorType: string;
message: string;
causes: string[];
}

View file

@ -1,7 +1,3 @@
// 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 { export interface UpdateTextDocumentVersion { parentVersionId: number, relativePath: string | null, content: (number | string)[], }
parentVersionId: number;
relativePath: string;
content: (number | string)[];
}

View file

@ -4,7 +4,4 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
/** /**
* Response to a vault history request (paginated). * Response to a vault history request (paginated).
*/ */
export interface VaultHistoryResponse { export interface VaultHistoryResponse { versions: DocumentVersionWithoutContent[], hasMore: boolean, }
versions: DocumentVersionWithoutContent[];
hasMore: boolean;
}

View file

@ -3,8 +3,4 @@
/** /**
* Summary of a single vault returned by the list-vaults endpoint. * Summary of a single vault returned by the list-vaults endpoint.
*/ */
export interface VaultInfo { export interface VaultInfo { name: string, documentCount: number, createdAt: string | null, }
name: string;
documentCount: number;
createdAt: string | null;
}

View file

@ -2,6 +2,4 @@
import type { CursorPositionFromClient } from "./CursorPositionFromClient"; import type { CursorPositionFromClient } from "./CursorPositionFromClient";
import type { WebSocketHandshake } from "./WebSocketHandshake"; import type { WebSocketHandshake } from "./WebSocketHandshake";
export type WebSocketClientMessage = export type WebSocketClientMessage = { "type": "handshake" } & WebSocketHandshake | { "type": "cursorPositions" } & CursorPositionFromClient;
| ({ type: "handshake" } & WebSocketHandshake)
| ({ type: "cursorPositions" } & CursorPositionFromClient);

View file

@ -1,7 +1,3 @@
// 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 { export interface WebSocketHandshake { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, }
token: string;
deviceId: string;
lastSeenVaultUpdateId: number | null;
}

View file

@ -2,6 +2,4 @@
import type { CursorPositionFromServer } from "./CursorPositionFromServer"; import type { CursorPositionFromServer } from "./CursorPositionFromServer";
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
export type WebSocketServerMessage = export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer;
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
| ({ type: "cursorPositions" } & CursorPositionFromServer);

View file

@ -1,6 +1,4 @@
// 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 { export interface WebSocketVaultUpdate { document: DocumentVersionWithoutContent, }
document: DocumentVersionWithoutContent;
}

View file

@ -30,6 +30,7 @@ function fakeRemoteVersion(
userId: "user", userId: "user",
deviceId: "device", deviceId: "device",
contentSize: 100, contentSize: 100,
isNewFile: true,
...overrides ...overrides
}; };
} }

View file

@ -446,19 +446,8 @@ export class Syncer {
): Promise<void> { ): Promise<void> {
const documentId = await event.documentId; 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({ const response = await this.syncService.delete({
documentId, documentId,
relativePath: doc.path
}); });
// Don't remove the doc from the queue or advance lastSeenUpdateId // Don't remove the doc from the queue or advance lastSeenUpdateId
@ -471,7 +460,7 @@ export class Syncer {
status: SyncStatus.SUCCESS, status: SyncStatus.SUCCESS,
details: { details: {
type: SyncType.DELETE, type: SyncType.DELETE,
relativePath: doc.path relativePath: event.path
}, },
message: "Successfully deleted file on the server", message: "Successfully deleted file on the server",
author: response.userId, author: response.userId,
@ -499,8 +488,21 @@ export class Syncer {
const contentBytes = await this.operations.read(diskPath); const contentBytes = await this.operations.read(diskPath);
const contentHash = await hash(contentBytes); 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 hashChanged = contentHash !== record.remoteHash;
const pathChanged = record.remoteRelativePath !== event.originalPath; const pathChanged =
renameTarget !== undefined &&
record.remoteRelativePath !== renameTarget;
if (!hashChanged && !pathChanged) { if (!hashChanged && !pathChanged) {
this.logger.debug( this.logger.debug(
@ -511,12 +513,16 @@ export class Syncer {
const response = await this.sendUpdate({ const response = await this.sendUpdate({
record, record,
relativePath: event.originalPath, relativePath: renameTarget,
contentBytes contentBytes
}); });
if (response.isDeleted) { if (response.isDeleted) {
await this.processRemoteDelete(diskPath, { ...response, contentSize: 0 }); await this.processRemoteDelete(diskPath, {
...response,
contentSize: 0,
isNewFile: false
});
return; 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); return this.processRemoteCreateForNewDocument(remoteVersion);
} }
@ -889,13 +903,15 @@ export class Syncer {
contentBytes contentBytes
}: { }: {
record: DocumentRecord; 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; contentBytes: Uint8Array;
}): Promise<DocumentUpdateResponse> { }): Promise<DocumentUpdateResponse> {
const isText = const isText =
!isBinary(contentBytes) && !isBinary(contentBytes) &&
isFileTypeMergable( isFileTypeMergable(
relativePath, relativePath ?? record.remoteRelativePath,
(await this.serverConfig.getConfig()).mergeableFileExtensions (await this.serverConfig.getConfig()).mergeableFileExtensions
); );

View file

@ -35,35 +35,36 @@ export enum SyncEventType {
export type FileSyncEvent = export type FileSyncEvent =
| { type: SyncEventType.LocalCreate; path: RelativePath } | { type: SyncEventType.LocalCreate; path: RelativePath }
| { | {
type: SyncEventType.LocalUpdate; type: SyncEventType.LocalUpdate;
path: RelativePath; path: RelativePath;
oldPath?: RelativePath; // oldPath is undefined for content changes oldPath?: RelativePath; // oldPath is undefined for content changes
} }
| { type: SyncEventType.LocalDelete; path: RelativePath } | { type: SyncEventType.LocalDelete; path: RelativePath }
| { | {
type: SyncEventType.RemoteChange; type: SyncEventType.RemoteChange;
remoteVersion: DocumentVersionWithoutContent; remoteVersion: DocumentVersionWithoutContent;
}; };
export type SyncEvent = export type SyncEvent =
| { | {
type: SyncEventType.LocalCreate; type: SyncEventType.LocalCreate;
path: RelativePath; // current path on disk path: RelativePath; // current path on disk
originalPath: RelativePath; // original path on disk when the event was queued originalPath: RelativePath; // original path on disk when the event was queued
resolvers: PromiseWithResolvers<DocumentId>; resolvers: PromiseWithResolvers<DocumentId>;
} }
| { | {
type: SyncEventType.LocalUpdate; type: SyncEventType.LocalUpdate;
documentId: DocumentId | Promise<DocumentId>; // if it's a promise, the promise is fulfilled once the document's create event is processed documentId: DocumentId | Promise<DocumentId>; // if it's a promise, the promise is fulfilled once the document's create event is processed
path: RelativePath; // current path on disk path: RelativePath; // current path on disk
originalPath: RelativePath; // original path on disk when the event was queued 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 isUserRename: boolean; // true iff this event was queued because the user renamed the file
} }
| { | {
type: SyncEventType.LocalDelete; type: SyncEventType.LocalDelete;
documentId: DocumentId | Promise<DocumentId>; // if it's a promise, the promise is fulfilled once the document's create event is processed documentId: DocumentId | Promise<DocumentId>; // 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; type: SyncEventType.RemoteChange;
remoteVersion: DocumentVersionWithoutContent; remoteVersion: DocumentVersionWithoutContent;
}; };