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 { ConnectedState } from "./connected-state"; export interface CheckConnectionResult { isSuccessful: boolean; message: string; } export class SyncService { private client!: Client; private clientWithoutRetries!: Client; private _fetchImplementation: typeof globalThis.fetch = globalThis.fetch; public constructor( private readonly connectedState: ConnectedState, private readonly settings: Settings, private readonly logger: Logger ) { this.createClient(this.settings.getSettings().remoteUri); settings.addOnSettingsChangeHandlers((newSettings, oldSettings) => { if (newSettings.remoteUri === oldSettings.remoteUri) { return; } this.createClient(newSettings.remoteUri); }); } public set fetchImplementation(fetch: typeof globalThis.fetch) { this._fetchImplementation = fetch; 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 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: this.settings.getSettings().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 { 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: this.settings.getSettings().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 { const response = await this.client.DELETE( "/vaults/{vault_id}/documents/{document_id}", { params: { path: { vault_id: this.settings.getSettings().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 response = await this.client.GET( "/vaults/{vault_id}/documents/{document_id}", { params: { path: { vault_id: this.settings.getSettings().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 { const response = await this.client.GET("/vaults/{vault_id}/documents", { params: { path: { vault_id: this.settings.getSettings().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}` }; } } private async ping(): Promise { const response = await this.clientWithoutRetries.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; } private createClient(remoteUri: string): void { this.client = createClient({ baseUrl: remoteUri, fetch: this.connectedState.getFetchImplementation( this._fetchImplementation ) }); this.clientWithoutRetries = createClient({ baseUrl: remoteUri, fetch: this.connectedState.getFetchImplementation( this._fetchImplementation, { doRetries: false } ) }); } }