Add API for propagating cursor locations (#61)
This commit is contained in:
parent
f97193e287
commit
e8b9bf40c5
80 changed files with 1930 additions and 2229 deletions
|
|
@ -1,16 +1,21 @@
|
|||
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";
|
||||
import type { SerializedError } from "./types/SerializedError";
|
||||
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
||||
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";
|
||||
|
||||
export interface CheckConnectionResult {
|
||||
isSuccessful: boolean;
|
||||
|
|
@ -19,47 +24,28 @@ export interface CheckConnectionResult {
|
|||
|
||||
export class SyncService {
|
||||
private static readonly NETWORK_RETRY_INTERVAL_MS = 1000;
|
||||
private client: Client<paths>;
|
||||
private pingClient: Client<paths>;
|
||||
private readonly client: typeof globalThis.fetch;
|
||||
private readonly pingClient: typeof globalThis.fetch;
|
||||
|
||||
public constructor(
|
||||
private readonly deviceId: string,
|
||||
private readonly connectionStatus: ConnectionStatus,
|
||||
private readonly settings: Settings,
|
||||
private readonly logger: Logger,
|
||||
private readonly fetchImplementation: typeof globalThis.fetch = globalThis.fetch
|
||||
fetchImplementation: typeof globalThis.fetch = globalThis.fetch
|
||||
) {
|
||||
[this.client, this.pingClient] = this.createClient(
|
||||
this.settings.getSettings().remoteUri
|
||||
// ensure that if it's called a method, `this` won't be bound to the instance
|
||||
const unboundFetch: typeof globalThis.fetch = async (...args) =>
|
||||
fetchImplementation(...args);
|
||||
|
||||
this.client = this.connectionStatus.getFetchImplementation(
|
||||
this.logger,
|
||||
unboundFetch
|
||||
);
|
||||
|
||||
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
|
||||
if (newSettings.remoteUri === oldSettings.remoteUri) {
|
||||
return;
|
||||
}
|
||||
|
||||
[this.client, this.pingClient] = this.createClient(
|
||||
newSettings.remoteUri
|
||||
);
|
||||
});
|
||||
this.pingClient = unboundFetch;
|
||||
}
|
||||
|
||||
private get deviceIdHeader(): string {
|
||||
// @ts-expect-error, injected by webpack
|
||||
const packageVersion = __CURRENT_VERSION__; // eslint-disable-line
|
||||
|
||||
const platform =
|
||||
typeof navigator !== "undefined"
|
||||
? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated
|
||||
: typeof process !== "undefined"
|
||||
? process.platform
|
||||
: "unknown";
|
||||
return `vault-link/${packageVersion} (${this.deviceId}; ${platform})`;
|
||||
}
|
||||
|
||||
private static formatError(
|
||||
error: components["schemas"]["SerializedError"]
|
||||
): string {
|
||||
private static formatError(error: SerializedError): string {
|
||||
let result = error.message;
|
||||
if (error.causes.length > 0) {
|
||||
const causes = error.causes.join(", ");
|
||||
|
|
@ -77,47 +63,39 @@ export class SyncService {
|
|||
documentId?: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
contentBytes: Uint8Array;
|
||||
}): Promise<components["schemas"]["DocumentVersionWithoutContent"]> {
|
||||
const { vaultName } = this.settings.getSettings();
|
||||
|
||||
}): Promise<DocumentVersionWithoutContent> {
|
||||
return this.withRetries(async () => {
|
||||
const formData = new FormData();
|
||||
if (documentId !== undefined) {
|
||||
formData.append("document_id", documentId);
|
||||
}
|
||||
formData.append("relative_path", relativePath);
|
||||
formData.append("device_id", this.deviceId);
|
||||
formData.append("content", new Blob([contentBytes]));
|
||||
|
||||
const response = await this.client.POST(
|
||||
"/vaults/{vault_id}/documents",
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
vault_id: vaultName
|
||||
},
|
||||
header: {
|
||||
"device-id": this.deviceIdHeader
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line
|
||||
body: formData as any // FormData is not supported by openapi-fetch
|
||||
}
|
||||
);
|
||||
const response = await this.client(this.getUrl("/documents"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: this.getDefaultHeaders()
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
const result: SerializedError | DocumentVersionWithoutContent =
|
||||
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
| SerializedError
|
||||
| DocumentVersionWithoutContent;
|
||||
|
||||
if ("errorType" in result) {
|
||||
throw new Error(
|
||||
`Failed to create document: ${SyncService.formatError(response.error)}`
|
||||
`Failed to create document: ${SyncService.formatError(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Created document ${JSON.stringify(response.data)} with id ${
|
||||
response.data.documentId
|
||||
`Created document ${JSON.stringify(result)} with id ${
|
||||
result.documentId
|
||||
}`
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -131,9 +109,7 @@ export class SyncService {
|
|||
documentId: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
contentBytes: Uint8Array;
|
||||
}): Promise<components["schemas"]["DocumentUpdateResponse"]> {
|
||||
const { vaultName } = this.settings.getSettings();
|
||||
|
||||
}): Promise<DocumentUpdateResponse> {
|
||||
return this.withRetries(async () => {
|
||||
this.logger.debug(
|
||||
`Updating document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
|
||||
|
|
@ -141,39 +117,35 @@ export class SyncService {
|
|||
const formData = new FormData();
|
||||
formData.append("parent_version_id", parentVersionId.toString());
|
||||
formData.append("relative_path", relativePath);
|
||||
formData.append("device_id", this.deviceId);
|
||||
formData.append("content", new Blob([contentBytes]));
|
||||
|
||||
const response = await this.client.PUT(
|
||||
"/vaults/{vault_id}/documents/{document_id}",
|
||||
const response = await this.client(
|
||||
this.getUrl(`/documents/${documentId}`),
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
vault_id: vaultName,
|
||||
document_id: documentId
|
||||
},
|
||||
header: {
|
||||
"device-id": this.deviceIdHeader
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line
|
||||
body: formData as any // FormData is not supported by openapi-fetch
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
headers: this.getDefaultHeaders()
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
const result: SerializedError | DocumentUpdateResponse =
|
||||
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
| SerializedError
|
||||
| DocumentUpdateResponse;
|
||||
|
||||
if ("errorType" in result) {
|
||||
throw new Error(
|
||||
`Failed to update document: ${SyncService.formatError(response.error)}`
|
||||
`Failed to update document: ${SyncService.formatError(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Updated document ${JSON.stringify(response.data)} with id ${
|
||||
response.data.documentId
|
||||
}`
|
||||
`Updated document ${JSON.stringify(result)} with id ${
|
||||
result.documentId
|
||||
}}`
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -183,39 +155,39 @@ export class SyncService {
|
|||
}: {
|
||||
documentId: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
}): Promise<components["schemas"]["DocumentVersionWithoutContent"]> {
|
||||
}): Promise<DocumentVersionWithoutContent> {
|
||||
return this.withRetries(async () => {
|
||||
const { vaultName } = this.settings.getSettings();
|
||||
|
||||
const response = await this.client.DELETE(
|
||||
"/vaults/{vault_id}/documents/{document_id}",
|
||||
const request: DeleteDocumentVersion = {
|
||||
relativePath
|
||||
};
|
||||
const response = await this.client(
|
||||
this.getUrl(`/documents/${documentId}`),
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
vault_id: vaultName,
|
||||
document_id: documentId
|
||||
},
|
||||
header: {
|
||||
"device-id": this.deviceIdHeader
|
||||
}
|
||||
},
|
||||
|
||||
body: {
|
||||
relativePath,
|
||||
deviceId: this.deviceId
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(request),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...this.getDefaultHeaders()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to delete document`);
|
||||
const result: SerializedError | DocumentVersionWithoutContent =
|
||||
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
| SerializedError
|
||||
| DocumentVersionWithoutContent;
|
||||
|
||||
if ("errorType" in result) {
|
||||
throw new Error(
|
||||
`Failed to delete document: ${SyncService.formatError(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Deleted document ${relativePath} with id ${documentId}`
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -223,100 +195,77 @@ export class SyncService {
|
|||
documentId
|
||||
}: {
|
||||
documentId: DocumentId;
|
||||
}): Promise<components["schemas"]["DocumentVersion"]> {
|
||||
const { vaultName } = this.settings.getSettings();
|
||||
|
||||
}): Promise<DocumentVersion> {
|
||||
return this.withRetries(async () => {
|
||||
const response = await this.client.GET(
|
||||
"/vaults/{vault_id}/documents/{document_id}",
|
||||
const response = await this.client(
|
||||
this.getUrl(`/documents/${documentId}`),
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
vault_id: vaultName,
|
||||
document_id: documentId
|
||||
}
|
||||
}
|
||||
headers: this.getDefaultHeaders()
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
const result: SerializedError | DocumentVersion =
|
||||
(await response.json()) as SerializedError | DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
if ("errorType" in result) {
|
||||
throw new Error(
|
||||
`Failed to get document: ${SyncService.formatError(response.error)}`
|
||||
`Failed to get document: ${SyncService.formatError(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Get document ${response.data.relativePath} with id ${response.data.documentId}`
|
||||
`Get document ${result.relativePath} with id ${result.documentId}`
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public async getAll(
|
||||
since?: VaultUpdateId
|
||||
): Promise<components["schemas"]["FetchLatestDocumentsResponse"]> {
|
||||
): Promise<FetchLatestDocumentsResponse> {
|
||||
return this.withRetries(async () => {
|
||||
const { vaultName } = this.settings.getSettings();
|
||||
const url = new URL(this.getUrl("/documents"));
|
||||
if (since !== undefined) {
|
||||
url.searchParams.append("since", since.toString());
|
||||
}
|
||||
const response = await this.client(url.toString(), {
|
||||
headers: this.getDefaultHeaders()
|
||||
});
|
||||
|
||||
const response = await this.client.GET(
|
||||
"/vaults/{vault_id}/documents",
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
vault_id: vaultName
|
||||
},
|
||||
query: {
|
||||
since_update_id: since
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
const result: SerializedError | FetchLatestDocumentsResponse =
|
||||
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
| SerializedError
|
||||
| FetchLatestDocumentsResponse;
|
||||
|
||||
const { error } = response;
|
||||
if (error) {
|
||||
if ("errorType" in result) {
|
||||
throw new Error(
|
||||
`Failed to get documents: ${SyncService.formatError(response.error)}`
|
||||
`Failed to get documents: ${SyncService.formatError(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Got ${response.data.latestDocuments.length} document metadata`
|
||||
`Got ${result.latestDocuments.length} document metadata`
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public async checkConnection(): Promise<CheckConnectionResult> {
|
||||
const { vaultName } = this.settings.getSettings();
|
||||
|
||||
try {
|
||||
const response = await this.pingClient.GET(
|
||||
"/vaults/{vault_id}/ping",
|
||||
{
|
||||
params: {
|
||||
header: {
|
||||
authorization: `Bearer ${this.settings.getSettings().token}`
|
||||
},
|
||||
path: {
|
||||
vault_id: vaultName
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
const response = await this.pingClient(this.getUrl("/ping"), {
|
||||
headers: this.getDefaultHeaders()
|
||||
});
|
||||
const result: PingResponse | SerializedError =
|
||||
(await response.json()) as PingResponse | SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
this.logger.debug(
|
||||
`Ping response: ${JSON.stringify(response.data)}`
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
if ("errorType" in result) {
|
||||
throw new Error(
|
||||
`Failed to ping server: ${SyncService.formatError(response.error)}`
|
||||
`Failed to ping server: ${SyncService.formatError(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = response.data;
|
||||
if (result.isAuthenticated) {
|
||||
return {
|
||||
isSuccessful: true,
|
||||
|
|
@ -336,29 +285,17 @@ export class SyncService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a client and a ping client for the given remote URI.
|
||||
*/
|
||||
private createClient(remoteUri: string): [Client<paths>, Client<paths>] {
|
||||
return [
|
||||
createClient<paths>({
|
||||
baseUrl: remoteUri,
|
||||
fetch: this.connectionStatus.getFetchImplementation(
|
||||
this.logger,
|
||||
this.fetchImplementation
|
||||
),
|
||||
headers: {
|
||||
authorization: `Bearer ${this.settings.getSettings().token}`
|
||||
}
|
||||
}),
|
||||
createClient<paths>({
|
||||
baseUrl: remoteUri,
|
||||
fetch: this.fetchImplementation,
|
||||
headers: {
|
||||
authorization: `Bearer ${this.settings.getSettings().token}`
|
||||
}
|
||||
})
|
||||
];
|
||||
private getUrl(path: string): string {
|
||||
const { vaultName, remoteUri } = this.settings.getSettings();
|
||||
const safeRemoteUri = remoteUri.replace(/\/+$/, "");
|
||||
return `${safeRemoteUri}/vaults/${vaultName}${path}`;
|
||||
}
|
||||
|
||||
private getDefaultHeaders(): Record<string, string> {
|
||||
return {
|
||||
"device-id": this.deviceId,
|
||||
authorization: `Bearer ${this.settings.getSettings().token}`
|
||||
};
|
||||
}
|
||||
|
||||
private async withRetries<T>(fn: () => Promise<T>): Promise<T> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue