import type { Client } from "openapi-fetch"; import createClient from "openapi-fetch"; import type { components, paths } from "./types"; // generated by openapi-typescript import type { DocumentId, RelativePath, VaultUpdateId } from "../persistence/database"; import type { Logger } from "../tracing/logger"; import type { Settings } from "../persistence/settings"; import type { ConnectionStatus } from "./connection-status"; import { sleep } from "../utils/sleep"; import { SyncResetError } from "./sync-reset-error"; export interface CheckConnectionResult { isSuccessful: boolean; message: string; } export class SyncService { private client: Client; private pingClient: Client; private _fetchImplementation: typeof globalThis.fetch = globalThis.fetch; public constructor( private readonly connectionStatus: ConnectionStatus, private readonly settings: Settings, private readonly logger: Logger ) { [this.client, this.pingClient] = this.createClient( this.settings.getSettings().remoteUri ); settings.addOnSettingsChangeListener((newSettings, oldSettings) => { if (newSettings.remoteUri === oldSettings.remoteUri) { return; } [this.client, this.pingClient] = this.createClient( newSettings.remoteUri ); }); } public set fetchImplementation(fetch: typeof globalThis.fetch) { this._fetchImplementation = fetch; [this.client, this.pingClient] = this.createClient( this.settings.getSettings().remoteUri ); } private static formatError( error: components["schemas"]["SerializedError"] ): string { let result = error.message; if (error.causes.length > 0) { const causes = error.causes.join(", "); result += ` caused by: ${causes}`; } return result; } public async create({ documentId, relativePath, contentBytes }: { documentId?: DocumentId; relativePath: RelativePath; contentBytes: Uint8Array; }): Promise { const { vaultName } = this.settings.getSettings(); return this.withRetries(async () => { const formData = new FormData(); if (documentId !== undefined) { formData.append("document_id", documentId); } formData.append("relative_path", relativePath); formData.append("content", new Blob([contentBytes])); const response = await this.client.POST( "/vaults/{vault_id}/documents", { params: { path: { vault_id: vaultName }, header: { authorization: `Bearer ${this.settings.getSettings().token}` } }, // eslint-disable-next-line body: formData as any // FormData is not supported by openapi-fetch } ); if (!response.data) { throw new Error( `Failed to create document: ${SyncService.formatError(response.error)}` ); } this.logger.debug( `Created document ${JSON.stringify(response.data)} with id ${ response.data.documentId }` ); return response.data; }); } public async put({ parentVersionId, documentId, relativePath, contentBytes }: { parentVersionId: VaultUpdateId; documentId: DocumentId; relativePath: RelativePath; contentBytes: Uint8Array; }): Promise { const { vaultName } = this.settings.getSettings(); return this.withRetries(async () => { this.logger.debug( `Updating 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); formData.append("content", new Blob([contentBytes])); const response = await this.client.PUT( "/vaults/{vault_id}/documents/{document_id}", { params: { path: { vault_id: vaultName, document_id: documentId }, header: { authorization: `Bearer ${this.settings.getSettings().token}` } }, // eslint-disable-next-line body: formData as any // FormData is not supported by openapi-fetch } ); if (!response.data) { throw new Error( `Failed to update document: ${SyncService.formatError(response.error)}` ); } this.logger.debug( `Updated document ${JSON.stringify(response.data)} with id ${ response.data.documentId }` ); return response.data; }); } public async delete({ documentId, relativePath }: { documentId: DocumentId; relativePath: RelativePath; }): Promise { return this.withRetries(async () => { const { vaultName } = this.settings.getSettings(); const response = await this.client.DELETE( "/vaults/{vault_id}/documents/{document_id}", { params: { path: { vault_id: vaultName, document_id: documentId }, header: { authorization: `Bearer ${this.settings.getSettings().token}` } }, body: { relativePath } } ); if (response.error) { throw new Error(`Failed to delete document`); } this.logger.debug( `Deleted document ${relativePath} with id ${documentId}` ); return response.data; }); } public async get({ documentId }: { documentId: DocumentId; }): Promise { const { vaultName } = this.settings.getSettings(); return this.withRetries(async () => { const response = await this.client.GET( "/vaults/{vault_id}/documents/{document_id}", { params: { path: { vault_id: vaultName, document_id: documentId }, header: { authorization: `Bearer ${this.settings.getSettings().token}` } } } ); if (!response.data) { throw new Error( `Failed to get document: ${SyncService.formatError(response.error)}` ); } this.logger.debug( `Get document ${response.data.relativePath} with id ${response.data.documentId}` ); return response.data; }); } public async getAll( since?: VaultUpdateId ): Promise { return this.withRetries(async () => { const { vaultName } = this.settings.getSettings(); const response = await this.client.GET( "/vaults/{vault_id}/documents", { params: { path: { vault_id: vaultName }, header: { authorization: `Bearer ${this.settings.getSettings().token}` }, query: { since_update_id: since } } } ); const { error } = response; if (error) { throw new Error( `Failed to get documents: ${SyncService.formatError(response.error)}` ); } this.logger.debug( `Got ${response.data.latestDocuments.length} document metadata` ); return response.data; }); } public async checkConnection(): Promise { try { const result = await this.ping(); if (result.isAuthenticated) { return { isSuccessful: true, message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated.` }; } return { isSuccessful: false, message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate.` }; } catch (e) { return { isSuccessful: false, message: `Failed to connect to server: ${e}` }; } } // No retries private async ping(): Promise { const response = await this.pingClient.GET("/ping", { params: { header: { authorization: `Bearer ${this.settings.getSettings().token}` } } }); this.logger.debug(`Ping response: ${JSON.stringify(response.data)}`); if (!response.data) { throw new Error( `Failed to ping server: ${SyncService.formatError(response.error)}` ); } return response.data; } /** * Create a client and a ping client for the given remote URI. */ private createClient(remoteUri: string): [Client, Client] { return [ createClient({ baseUrl: remoteUri, fetch: this.connectionStatus.getFetchImplementation( this.logger, this._fetchImplementation ) }), createClient({ baseUrl: remoteUri, fetch: this._fetchImplementation }) ]; } private async withRetries(fn: () => Promise): Promise { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { try { return await fn(); } catch (e) { // We must not retry errors coming from reset if (e instanceof SyncResetError) { throw e; } this.logger.error(`Failed network call (${e}), retrying`); await sleep(1000); } } } }