Pick up vault history id and refactor aborting
This commit is contained in:
parent
6ac27e3b34
commit
e0e38597c3
1 changed files with 339 additions and 410 deletions
|
|
@ -1,105 +1,66 @@
|
|||
import type { Settings } from "../persistence/settings";
|
||||
import type { FetchController } from "./fetch-controller";
|
||||
import type { Logger } from "../tracing/logger";
|
||||
import { abortable } from "../utils/abortable";
|
||||
import { VaultHistoryStatus } from "../types/vault-history-status";
|
||||
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
|
||||
import type { DocumentVersion } from "./types/DocumentVersion";
|
||||
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
||||
import type { EventBatch } from "./types/EventBatch";
|
||||
import type { FileManifest } from "./types/FileManifest";
|
||||
import type { FileManifestUpdateResponse } from "./types/FileManifestUpdateResponse";
|
||||
import type { PutFileContent } from "./types/PutFileContent";
|
||||
import type { PushFileManifest } from "./types/PushFileManifest";
|
||||
import type { VaultSnapshot } from "./types/VaultSnapshot";
|
||||
import type { PingResponse } from "./types/PingResponse";
|
||||
import { AuthenticationError, PermanentSyncError } from "../errors/errors";
|
||||
import {
|
||||
AuthenticationError,
|
||||
PermanentSyncError,
|
||||
ServerHistoryChangedError,
|
||||
SyncResetError
|
||||
} from "../errors/errors";
|
||||
|
||||
export class SyncService {
|
||||
private readonly client: typeof fetch;
|
||||
private readonly rawFetch: typeof fetch;
|
||||
private session = new AbortController();
|
||||
|
||||
public constructor(
|
||||
private readonly deviceId: string,
|
||||
fetchController: FetchController,
|
||||
private readonly settings: Settings,
|
||||
logger: Logger,
|
||||
fetchImplementation: typeof fetch = globalThis.fetch
|
||||
private readonly fetchImplementation: typeof fetch = globalThis.fetch,
|
||||
private readonly history: {
|
||||
get: () => string | undefined;
|
||||
save: (checkpoint: string | undefined) => Promise<void>;
|
||||
}
|
||||
) {
|
||||
this.rawFetch = async (...args) => fetchImplementation(...args);
|
||||
this.client = fetchController.getControlledFetchImplementation(
|
||||
logger,
|
||||
this.rawFetch
|
||||
);
|
||||
this.pause();
|
||||
}
|
||||
|
||||
public getUrl(path: string): string {
|
||||
const { remoteUri, vaultName } = this.settings.getSettings();
|
||||
return `${remoteUri.replace(/\/$/u, "")}/vaults/${encodeURIComponent(vaultName)}${path}`;
|
||||
public get vaultHistoryStatus(): VaultHistoryStatus {
|
||||
return this.history.get() !== undefined
|
||||
? VaultHistoryStatus.STORED
|
||||
: VaultHistoryStatus.MISSING;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
raw = false
|
||||
): Promise<T> {
|
||||
const response = await (raw ? this.rawFetch : this.client)(
|
||||
this.getUrl(path),
|
||||
{
|
||||
method: body === undefined ? "GET" : "PUT",
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(
|
||||
this.settings.getSettings().requestTimeoutMs
|
||||
),
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.settings.getSettings().token}`,
|
||||
"Device-Id": this.deviceId,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
this.throwForErrorResponse(
|
||||
response,
|
||||
`HTTP ${response.status}: ${await response.text()}`
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
public pause(): void {
|
||||
this.session.abort(new SyncResetError());
|
||||
}
|
||||
|
||||
private throwForErrorResponse(
|
||||
response: Response,
|
||||
message: string
|
||||
): never {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new AuthenticationError(message);
|
||||
}
|
||||
if (
|
||||
response.status >= 400 &&
|
||||
response.status < 500 &&
|
||||
response.status !== 408 &&
|
||||
response.status !== 429
|
||||
) {
|
||||
throw new PermanentSyncError(message);
|
||||
}
|
||||
throw new Error(message);
|
||||
public resume(): void {
|
||||
this.session = new AbortController();
|
||||
}
|
||||
|
||||
public async resetHistory(): Promise<void> {
|
||||
await this.history.save(undefined);
|
||||
}
|
||||
|
||||
public async ping(): Promise<PingResponse> {
|
||||
return this.request("/ping", undefined, true);
|
||||
}
|
||||
|
||||
public async vaultSnapshot(): Promise<VaultSnapshot> {
|
||||
public async getVaultSnapshot(): Promise<VaultSnapshot> {
|
||||
return this.request("/vault-snapshot");
|
||||
}
|
||||
|
||||
public async events(after: number): Promise<EventBatch> {
|
||||
public async getEvents(after: number): Promise<EventBatch> {
|
||||
return this.request(`/events-since?after=${after}`);
|
||||
}
|
||||
|
||||
public async fileManifest(): Promise<FileManifest> {
|
||||
return this.request("/file-manifest");
|
||||
}
|
||||
|
||||
public async pushFileManifest(
|
||||
request: PushFileManifest
|
||||
): Promise<FileManifestUpdateResponse> {
|
||||
|
|
@ -113,8 +74,8 @@ export class SyncService {
|
|||
return this.request(`/documents/${id}`, request);
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<DocumentVersion> {
|
||||
return this.request(`/documents/${id}`);
|
||||
public async getDocumentMetadata(id: string): Promise<DocumentVersionWithoutContent> {
|
||||
return this.request(`/documents/${id}/metadata`);
|
||||
}
|
||||
|
||||
public async getDocumentVersionContent({
|
||||
|
|
@ -124,27 +85,111 @@ export class SyncService {
|
|||
documentId: string;
|
||||
vaultUpdateId: number;
|
||||
}): Promise<Uint8Array> {
|
||||
const response = await this.client(
|
||||
this.getUrl(
|
||||
`/documents/${documentId}/versions/${vaultUpdateId}/content`
|
||||
),
|
||||
{
|
||||
signal: AbortSignal.timeout(
|
||||
this.settings.getSettings().requestTimeoutMs
|
||||
),
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.settings.getSettings().token}`
|
||||
}
|
||||
}
|
||||
return this.request(
|
||||
`/documents/${documentId}/versions/${vaultUpdateId}/content`,
|
||||
undefined,
|
||||
false,
|
||||
async (response) => new Uint8Array(await response.arrayBuffer())
|
||||
);
|
||||
}
|
||||
|
||||
private getHistoryHeaders(): Record<string, string> {
|
||||
const checkpoint = this.history.get();
|
||||
return checkpoint !== undefined && checkpoint !== ""
|
||||
? { "X-Vault-Link-History": checkpoint }
|
||||
: {};
|
||||
}
|
||||
|
||||
private async recordHistory(response: Response): Promise<void> {
|
||||
const checkpoint = response.headers.get("x-vault-link-history");
|
||||
if (
|
||||
checkpoint !== null &&
|
||||
checkpoint !== ""
|
||||
) {
|
||||
const previous = this.history.get();
|
||||
if (checkpoint === previous) return;
|
||||
if (
|
||||
previous === undefined ||
|
||||
previous === "" ||
|
||||
Number(checkpoint.split(":")[0]) >=
|
||||
Number(previous.split(":")[0])
|
||||
)
|
||||
await this.history.save(checkpoint);
|
||||
}
|
||||
}
|
||||
|
||||
private checkHistory(response: Response): void {
|
||||
if (response.headers.get("x-vault-link-history-mismatch") === "1")
|
||||
throw new ServerHistoryChangedError(
|
||||
"Server history changed; recovering local work"
|
||||
);
|
||||
}
|
||||
|
||||
private getUrl(path: string): string {
|
||||
const { remoteUri, vaultName } = this.settings.getSettings();
|
||||
return `${remoteUri.replace(/\/$/u, "")}/vaults/${encodeURIComponent(vaultName)}${path}`;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
raw = false,
|
||||
decode: (response: Response) => Promise<T> = async (response) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Endpoint methods supply the generated protocol response type.
|
||||
response.json() as Promise<T>
|
||||
): Promise<T> {
|
||||
const timeout = AbortSignal.timeout(
|
||||
this.settings.getSettings().requestTimeoutMs
|
||||
);
|
||||
|
||||
const signal = raw
|
||||
? timeout
|
||||
: AbortSignal.any([timeout, this.session.signal]);
|
||||
|
||||
const response = await abortable(signal, async () =>
|
||||
this.fetchImplementation(this.getUrl(path), {
|
||||
method: body === undefined ? "GET" : "PUT",
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal,
|
||||
headers: {
|
||||
...(raw ? {} : this.getHistoryHeaders()),
|
||||
Authorization: `Bearer ${this.settings.getSettings().token}`,
|
||||
"Device-Id": this.deviceId,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
signal.throwIfAborted();
|
||||
this.checkHistory(response);
|
||||
|
||||
if (!response.ok) {
|
||||
this.throwForErrorResponse(
|
||||
response,
|
||||
`Cannot fetch version ${vaultUpdateId}: HTTP ${response.status}`
|
||||
`HTTP ${response.status}: ${await abortable(signal, async () => response.text())}`
|
||||
);
|
||||
}
|
||||
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
// Finish metadata saves even if the transport is paused meanwhile.
|
||||
if (!raw) await this.recordHistory(response);
|
||||
|
||||
return abortable(signal, async () => decode(response));
|
||||
}
|
||||
|
||||
private throwForErrorResponse(response: Response, message: string): never {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new AuthenticationError(message);
|
||||
}
|
||||
|
||||
if (
|
||||
response.status >= 400 &&
|
||||
response.status < 500 &&
|
||||
response.status !== 408 &&
|
||||
response.status !== 429
|
||||
) {
|
||||
throw new PermanentSyncError(message);
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue