This commit is contained in:
Andras Schmelczer 2026-04-23 20:35:42 +01:00
parent 6a8c7635f1
commit d715d94b6d
26 changed files with 1007 additions and 453 deletions

View file

@ -16,12 +16,12 @@ import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
import type { DocumentVersion } from "./types/DocumentVersion";
import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse";
import type { PingResponse } from "./types/PingResponse";
import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion";
import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion";
export class SyncService {
private readonly client: typeof globalThis.fetch;
private readonly pingClient: typeof globalThis.fetch;
private isStopped = false;
public constructor(
private readonly deviceId: string,
@ -68,15 +68,21 @@ export class SyncService {
public async create({
relativePath,
lastSeenVaultUpdateId,
contentBytes
}: {
relativePath: RelativePath;
lastSeenVaultUpdateId: VaultUpdateId;
contentBytes: Uint8Array;
}): Promise<DocumentUpdateResponse> {
return this.retryForever(async () => {
const formData = new FormData();
formData.append("relative_path", relativePath);
formData.append(
"last_seen_vault_update_id",
lastSeenVaultUpdateId.toString()
);
formData.append(
"content",
new Blob([new Uint8Array(contentBytes)])
@ -92,13 +98,7 @@ export class SyncService {
headers: this.getDefaultHeaders()
});
if (!response.ok) {
throw new Error(
`Failed to create document: ${await SyncService.errorFromResponse(
response
)}`
);
}
await SyncService.throwIfNotOk(response, "create document");
const result: DocumentUpdateResponse =
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
@ -210,30 +210,21 @@ export class SyncService {
relativePath: RelativePath;
}): Promise<DocumentVersionWithoutContent> {
return this.retryForever(async () => {
const request: DeleteDocumentVersion = {
relativePath
};
this.logger.debug(
`Delete document with id ${documentId} and relative path ${relativePath}`
);
// The server identifies the document by its URL path; no body
// is needed. Sending one was a leftover of an earlier shape.
const response = await this.client(
this.getUrl(`/documents/${documentId}`),
{
method: "DELETE",
body: JSON.stringify(request),
headers: this.getDefaultHeaders({ type: "json" })
headers: this.getDefaultHeaders()
}
);
if (!response.ok) {
throw new Error(
`Failed to delete document: ${await SyncService.errorFromResponse(
response
)}`
);
}
await SyncService.throwIfNotOk(response, "delete document");
const result: DocumentVersionWithoutContent =
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
@ -261,13 +252,7 @@ export class SyncService {
}
);
if (!response.ok) {
throw new Error(
`Failed to get document: ${await SyncService.errorFromResponse(
response
)}`
);
}
await SyncService.throwIfNotOk(response, "get document");
const result: DocumentVersion =
(await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
@ -299,13 +284,7 @@ export class SyncService {
}
);
if (!response.ok) {
throw new Error(
`Failed to get document: ${await SyncService.errorFromResponse(
response
)}`
);
}
await SyncService.throwIfNotOk(response, "get document version content");
const result = await response.bytes();
this.logger.debug(
@ -332,13 +311,7 @@ export class SyncService {
headers: this.getDefaultHeaders()
});
if (!response.ok) {
throw new Error(
`Failed to get documents: ${await SyncService.errorFromResponse(
response
)}`
);
}
await SyncService.throwIfNotOk(response, "get documents");
const result: FetchLatestDocumentsResponse =
(await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
@ -396,9 +369,30 @@ export class SyncService {
return headers;
}
/**
* Signal that the service is shutting down so any in-flight
* `retryForever` exits at its next iteration instead of looping
* indefinitely after the rest of the client has stopped. Idempotent.
*/
public stop(): void {
this.isStopped = true;
}
/**
* Re-enable the service after a `stop()`. Used when the client pauses
* and resumes syncing within the same lifecycle (e.g. user toggles
* sync off and on).
*/
public resume(): void {
this.isStopped = false;
}
private async retryForever<T>(fn: () => Promise<T>): Promise<T> {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
if (this.isStopped) {
throw new SyncResetError();
}
try {
return await fn();
} catch (e) {
@ -408,6 +402,9 @@ export class SyncService {
) {
throw e;
}
if (this.isStopped) {
throw new SyncResetError();
}
const retryInterval =
this.settings.getSettings().networkRetryIntervalMs;
@ -425,6 +422,12 @@ export class SyncService {
): Promise<void> {
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
// is permanently rejected and shouldn't be retried.
if (response.status === 429) {
throw new Error(message);
}
if (response.status >= 400 && response.status < 500) {
throw new HttpClientError(response.status, message);
}

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.
export interface CreateDocumentVersion { relative_path: string, content: number[], }
export interface CreateDocumentVersion { relative_path: string, last_seen_vault_update_id: number, content: number[], }

View file

@ -1,5 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DeleteDocumentVersion {
relativePath: string;
}

View file

@ -4,9 +4,5 @@ import type { DocumentUpdateMetadata } from "./DocumentUpdateMetadata";
/**
* Response to a create/update document request.
*
* Neither variant contains `relative_path`: the client tracks the document's
* on-disk path locally and the server is the authority on document identity
* (`document_id`), not on its path.
*/
export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentUpdateMetadata | { "type": "MergingUpdate" } & DocumentUpdateMergedContent;

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.
import type { CursorSpan } from "./CursorSpan";
export interface DocumentWithCursors { vault_update_id: number | null, document_id: string, relative_path: string, cursors: CursorSpan[], }
export interface DocumentWithCursors { vaultUpdateId: number | null, documentId: string, relativePath: string, cursors: CursorSpan[], }

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.
/**
* A rename notification. Emitted whenever a write commits a document at
* a path that differs from what the origin client sent and/or from the
* document's previous stored path. Unlike [`WebSocketVaultUpdate`] this
* event is delivered to all subscribers *including the origin device*,
* because the create/update HTTP response no longer carries the path and
* the origin needs this event to learn the server-canonical path
* (e.g. when the server deduped or rejected a rename).
*/
export interface WebSocketVaultPathChange { vaultUpdateId: number, documentId: string, relativePath: string, }
export interface WebSocketVaultPathChange { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, userId: string, deviceId: string, }

View file

@ -181,6 +181,12 @@ export class WebSocketManager {
`Failed to close previous WebSocket connection: ${e}`
);
}
// Abandon any outstanding handler promises from the previous
// connection. They'll still resolve in the background, but we
// no longer want `waitUntilFinished` / `stop` to block on
// post-reconnect state — and we definitely don't want their
// results applied against a now-stale socket.
this.outstandingPromises.length = 0;
}
const wsUri = new URL(this.settings.getSettings().remoteUri);