Format & lint
This commit is contained in:
parent
fefac224b0
commit
7f62273e72
179 changed files with 2210 additions and 1319 deletions
|
|
@ -8,6 +8,7 @@ import type { FileSystemOperations } from "./filesystem-operations";
|
|||
import type { TextWithCursors } from "reconcile-text";
|
||||
import type { ServerConfig, ServerConfigData } from "../services/server-config";
|
||||
import { CONFLICT_PATH_REGEX } from "../sync-operations/conflict-path";
|
||||
import { removeFromArray } from "../utils/remove-from-array";
|
||||
|
||||
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
||||
public async getConfig(): Promise<ServerConfigData> {
|
||||
|
|
@ -81,9 +82,7 @@ function singleConflictPath(
|
|||
expectedNonConflictNames: string[]
|
||||
): string {
|
||||
const expected = new Set(expectedNonConflictNames);
|
||||
const conflicts = Array.from(names).filter(
|
||||
(name) => !expected.has(name)
|
||||
);
|
||||
const conflicts = Array.from(names).filter((name) => !expected.has(name));
|
||||
assert.equal(
|
||||
conflicts.length,
|
||||
1,
|
||||
|
|
@ -139,7 +138,11 @@ describe("File operations", () => {
|
|||
it("move with EXISTING displaces the target to a conflict path", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await ops.create("source.md", new Uint8Array(), MoveOnConflict.EXISTING);
|
||||
await ops.create(
|
||||
"source.md",
|
||||
new Uint8Array(),
|
||||
MoveOnConflict.EXISTING
|
||||
);
|
||||
await ops.create("dest.md", new Uint8Array(), MoveOnConflict.EXISTING);
|
||||
|
||||
await ops.move("source.md", "dest.md", MoveOnConflict.EXISTING);
|
||||
|
|
@ -156,7 +159,11 @@ describe("File operations", () => {
|
|||
it("move with NEW redirects the moved file to a conflict path", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await ops.create("source.md", new Uint8Array(), MoveOnConflict.EXISTING);
|
||||
await ops.create(
|
||||
"source.md",
|
||||
new Uint8Array(),
|
||||
MoveOnConflict.EXISTING
|
||||
);
|
||||
await ops.create("dest.md", new Uint8Array(), MoveOnConflict.EXISTING);
|
||||
|
||||
await ops.move("source.md", "dest.md", MoveOnConflict.NEW);
|
||||
|
|
@ -190,7 +197,11 @@ describe("File operations", () => {
|
|||
it("handles dotfiles without mangling the extension", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await ops.create(".gitignore", new Uint8Array(), MoveOnConflict.EXISTING);
|
||||
await ops.create(
|
||||
".gitignore",
|
||||
new Uint8Array(),
|
||||
MoveOnConflict.EXISTING
|
||||
);
|
||||
await ops.create("temp", new Uint8Array(), MoveOnConflict.EXISTING);
|
||||
await ops.move("temp", ".gitignore", MoveOnConflict.EXISTING);
|
||||
|
||||
|
|
@ -200,7 +211,11 @@ describe("File operations", () => {
|
|||
`conflict should preserve the dotfile name verbatim, got ${conflict}`
|
||||
);
|
||||
|
||||
await ops.create(".config.json", new Uint8Array(), MoveOnConflict.EXISTING);
|
||||
await ops.create(
|
||||
".config.json",
|
||||
new Uint8Array(),
|
||||
MoveOnConflict.EXISTING
|
||||
);
|
||||
await ops.create("temp2", new Uint8Array(), MoveOnConflict.EXISTING);
|
||||
await ops.move("temp2", ".config.json", MoveOnConflict.EXISTING);
|
||||
|
||||
|
|
@ -221,7 +236,8 @@ describe("File operations", () => {
|
|||
await ops.create("x", new Uint8Array(), MoveOnConflict.EXISTING);
|
||||
await ops.create("x", new Uint8Array(), MoveOnConflict.EXISTING);
|
||||
|
||||
const conflicts = Array.from(fs.names).filter((n) => n !== "x");
|
||||
const conflicts = Array.from(fs.names);
|
||||
removeFromArray(conflicts, "x");
|
||||
assert.equal(conflicts.length, 2);
|
||||
assert.ok(conflicts.every((c) => CONFLICT_PATH_REGEX.test(c)));
|
||||
assert.notEqual(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { Logger } from "../tracing/logger";
|
||||
import type { FileSystemOperations } from "./filesystem-operations";
|
||||
import type { RelativePath } from "../sync-operations/types";
|
||||
import type { SyncEventQueue } from "../sync-operations/sync-event-queue";
|
||||
import { SafeFileSystemOperations } from "./safe-filesystem-operations";
|
||||
import type { TextWithCursors } from "reconcile-text";
|
||||
import { reconcile } from "reconcile-text";
|
||||
|
|
@ -10,10 +9,9 @@ import { isBinary } from "../utils/is-binary";
|
|||
import { buildConflictFileName } from "../sync-operations/conflict-path";
|
||||
import type { ServerConfig } from "../services/server-config";
|
||||
|
||||
|
||||
export enum MoveOnConflict {
|
||||
EXISTING = "EXISTING",
|
||||
NEW = "NEW",
|
||||
NEW = "NEW"
|
||||
}
|
||||
|
||||
export class FileOperations {
|
||||
|
|
@ -40,6 +38,17 @@ export class FileOperations {
|
|||
return [pathParts.join("/"), fileName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a local-only conflict path for a file the client has to set aside.
|
||||
* Format: `<dir>/conflict-<uuid>-<originalName>` — UUID makes collisions
|
||||
* statistically impossible, so no disk probe / lock dance is needed.
|
||||
*/
|
||||
private static buildConflictPath(path: RelativePath): RelativePath {
|
||||
const [directory, fileName] = FileOperations.getParentDirAndFile(path);
|
||||
const conflictName = buildConflictFileName(fileName);
|
||||
return directory ? `${directory}/${conflictName}` : conflictName;
|
||||
}
|
||||
|
||||
public async listFilesRecursively(
|
||||
root: RelativePath | undefined = undefined
|
||||
): Promise<RelativePath[]> {
|
||||
|
|
@ -55,7 +64,7 @@ export class FileOperations {
|
|||
*
|
||||
* If a file with the same name already exists, it is moved before creating the new one.
|
||||
* Parent directories are created if necessary.
|
||||
*
|
||||
*
|
||||
* Returns the actual path the file was created at.
|
||||
*/
|
||||
public async create(
|
||||
|
|
@ -68,30 +77,6 @@ export class FileOperations {
|
|||
return actualPath;
|
||||
}
|
||||
|
||||
private async ensureClearPath(
|
||||
path: RelativePath,
|
||||
moveOnConflict: MoveOnConflict
|
||||
): Promise<RelativePath> {
|
||||
if (await this.fs.exists(path)) {
|
||||
const conflictPath = FileOperations.buildConflictPath(path);
|
||||
|
||||
if (moveOnConflict === MoveOnConflict.NEW) {
|
||||
return conflictPath;
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Displacing existing file at ${path} to '${conflictPath}' to make room`
|
||||
);
|
||||
|
||||
await this.fs.rename(path, conflictPath);
|
||||
return path;
|
||||
}
|
||||
|
||||
this.logger.debug(`No existing file at ${path}, creating parent directories if needed`);
|
||||
await this.createParentDirectories(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the file at the given path.
|
||||
*
|
||||
|
|
@ -129,8 +114,8 @@ export class FileOperations {
|
|||
return;
|
||||
}
|
||||
|
||||
let expectedText: string;
|
||||
let newText: string;
|
||||
let expectedText = "";
|
||||
let newText = "";
|
||||
try {
|
||||
expectedText = new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
expectedContent
|
||||
|
|
@ -206,6 +191,31 @@ export class FileOperations {
|
|||
return actualPath;
|
||||
}
|
||||
|
||||
private async ensureClearPath(
|
||||
path: RelativePath,
|
||||
moveOnConflict: MoveOnConflict
|
||||
): Promise<RelativePath> {
|
||||
if (await this.fs.exists(path)) {
|
||||
const conflictPath = FileOperations.buildConflictPath(path);
|
||||
|
||||
if (moveOnConflict === MoveOnConflict.NEW) {
|
||||
return conflictPath;
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Displacing existing file at ${path} to '${conflictPath}' to make room`
|
||||
);
|
||||
|
||||
await this.fs.rename(path, conflictPath);
|
||||
return path;
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`No existing file at ${path}, creating parent directories if needed`
|
||||
);
|
||||
await this.createParentDirectories(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private async deletingEmptyParentDirectoriesOfDeletedFile(
|
||||
path: RelativePath
|
||||
|
|
@ -265,16 +275,4 @@ export class FileOperations {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a local-only conflict path for a file the client has to set aside.
|
||||
* Format: `<dir>/conflict-<uuid>-<originalName>` — UUID makes collisions
|
||||
* statistically impossible, so no disk probe / lock dance is needed.
|
||||
*/
|
||||
private static buildConflictPath(path: RelativePath): RelativePath {
|
||||
const [directory, fileName] =
|
||||
FileOperations.getParentDirAndFile(path);
|
||||
const conflictName = buildConflictFileName(fileName);
|
||||
return directory ? `${directory}/${conflictName}` : conflictName;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@ export {
|
|||
export { Logger, LogLevel, LogLine } from "./tracing/logger";
|
||||
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
|
||||
export { rateLimit } from "./utils/rate-limit";
|
||||
export type { RelativePath, StoredSyncState as StoredDatabase, DocumentRecord } from "./sync-operations/types";
|
||||
export type {
|
||||
RelativePath,
|
||||
StoredSyncState as StoredDatabase,
|
||||
DocumentRecord
|
||||
} from "./sync-operations/types";
|
||||
export type { FileSystemOperations } from "./file-operations/filesystem-operations";
|
||||
export type { PersistenceProvider } from "./persistence/persistence";
|
||||
export type { CursorSpan } from "./services/types/CursorSpan";
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ export class FetchController {
|
|||
private _canFetch: boolean,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
({ promise: this.until, resolve: this.resolveUntil, reject: this.rejectUntil } = Promise.withResolvers<symbol>());
|
||||
({
|
||||
promise: this.until,
|
||||
resolve: this.resolveUntil,
|
||||
reject: this.rejectUntil
|
||||
} = Promise.withResolvers<symbol>());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -40,7 +44,11 @@ export class FetchController {
|
|||
|
||||
if (!this.isResetting) {
|
||||
const previousResolve = this.resolveUntil;
|
||||
({ promise: this.until, resolve: this.resolveUntil, reject: this.rejectUntil } = Promise.withResolvers<symbol>());
|
||||
({
|
||||
promise: this.until,
|
||||
resolve: this.resolveUntil,
|
||||
reject: this.rejectUntil
|
||||
} = Promise.withResolvers<symbol>());
|
||||
previousResolve(FetchController.UNTIL_RESOLUTION);
|
||||
}
|
||||
}
|
||||
|
|
@ -78,7 +86,11 @@ export class FetchController {
|
|||
}
|
||||
|
||||
this.isResetting = false;
|
||||
({ promise: this.until, resolve: this.resolveUntil, reject: this.rejectUntil } = Promise.withResolvers<symbol>());
|
||||
({
|
||||
promise: this.until,
|
||||
resolve: this.resolveUntil,
|
||||
reject: this.rejectUntil
|
||||
} = Promise.withResolvers<symbol>());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -66,6 +66,42 @@ export class SyncService {
|
|||
return result;
|
||||
}
|
||||
|
||||
private static async throwIfNotOk(
|
||||
response: Response,
|
||||
operation: string
|
||||
): 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);
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
public async create({
|
||||
relativePath,
|
||||
lastSeenVaultUpdateId,
|
||||
|
|
@ -146,8 +182,7 @@ export class SyncService {
|
|||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
this.logger.debug(
|
||||
`Updated document ${JSON.stringify(result)} with id ${
|
||||
result.documentId
|
||||
`Updated document ${JSON.stringify(result)} with id ${result.documentId
|
||||
}}`
|
||||
);
|
||||
|
||||
|
|
@ -193,8 +228,7 @@ export class SyncService {
|
|||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
this.logger.debug(
|
||||
`Updated document ${JSON.stringify(result)} with id ${
|
||||
result.documentId
|
||||
`Updated document ${JSON.stringify(result)} with id ${result.documentId
|
||||
}}`
|
||||
);
|
||||
|
||||
|
|
@ -284,7 +318,10 @@ export class SyncService {
|
|||
}
|
||||
);
|
||||
|
||||
await SyncService.throwIfNotOk(response, "get document version content");
|
||||
await SyncService.throwIfNotOk(
|
||||
response,
|
||||
"get document version content"
|
||||
);
|
||||
|
||||
const result = await response.bytes();
|
||||
this.logger.debug(
|
||||
|
|
@ -300,7 +337,7 @@ export class SyncService {
|
|||
return this.retryForever(async () => {
|
||||
this.logger.debug(
|
||||
"Getting all documents" +
|
||||
(since != null ? ` since ${since}` : "")
|
||||
(since != null ? ` since ${since}` : "")
|
||||
);
|
||||
|
||||
const url = new URL(this.getUrl("/documents"));
|
||||
|
|
@ -369,30 +406,10 @@ 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();
|
||||
}
|
||||
this.throwIfStopped();
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
|
|
@ -402,9 +419,7 @@ export class SyncService {
|
|||
) {
|
||||
throw e;
|
||||
}
|
||||
if (this.isStopped) {
|
||||
throw new SyncResetError();
|
||||
}
|
||||
this.throwIfStopped();
|
||||
|
||||
const retryInterval =
|
||||
this.settings.getSettings().networkRetryIntervalMs;
|
||||
|
|
@ -416,21 +431,9 @@ export class SyncService {
|
|||
}
|
||||
}
|
||||
|
||||
private static async throwIfNotOk(
|
||||
response: Response,
|
||||
operation: string
|
||||
): 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);
|
||||
private throwIfStopped(): void {
|
||||
if (this.isStopped) {
|
||||
throw new SyncResetError();
|
||||
}
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
throw new HttpClientError(response.status, message);
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||
|
||||
export interface ClientCursors { userName: string, deviceId: string, documentsWithCursors: DocumentWithCursors[], }
|
||||
export interface ClientCursors {
|
||||
userName: string;
|
||||
deviceId: string;
|
||||
documentsWithCursors: DocumentWithCursors[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
// 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, last_seen_vault_update_id: number, content: number[], }
|
||||
export interface CreateDocumentVersion {
|
||||
relative_path: string;
|
||||
last_seen_vault_update_id: number;
|
||||
content: number[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||
|
||||
export interface CursorPositionFromClient { documentsWithCursors: DocumentWithCursors[], }
|
||||
export interface CursorPositionFromClient {
|
||||
documentsWithCursors: DocumentWithCursors[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ClientCursors } from "./ClientCursors";
|
||||
|
||||
export interface CursorPositionFromServer { clients: ClientCursors[], }
|
||||
export interface CursorPositionFromServer {
|
||||
clients: ClientCursors[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export interface CursorSpan { start: number, end: number, }
|
||||
export interface CursorSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,4 +5,6 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
|
|||
/**
|
||||
* Response to a create/update document request.
|
||||
*/
|
||||
export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion;
|
||||
export type DocumentUpdateResponse =
|
||||
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
|
||||
| ({ type: "MergingUpdate" } & DocumentVersion);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,12 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export interface DocumentVersion { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, }
|
||||
export interface DocumentVersion {
|
||||
vaultUpdateId: number;
|
||||
documentId: string;
|
||||
relativePath: string;
|
||||
updatedDate: string;
|
||||
contentBase64: string;
|
||||
isDeleted: boolean;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,12 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export interface DocumentVersionWithoutContent { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, }
|
||||
export interface DocumentVersionWithoutContent {
|
||||
vaultUpdateId: number;
|
||||
documentId: string;
|
||||
relativePath: string;
|
||||
updatedDate: string;
|
||||
isDeleted: boolean;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
contentSize: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
// 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 { vaultUpdateId: number | null, documentId: string, relativePath: string, cursors: CursorSpan[], }
|
||||
export interface DocumentWithCursors {
|
||||
vaultUpdateId: number | null;
|
||||
documentId: string;
|
||||
relativePath: string;
|
||||
cursors: CursorSpan[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
|
|||
/**
|
||||
* Response to a fetch latest documents request.
|
||||
*/
|
||||
export interface FetchLatestDocumentsResponse { latestDocuments: DocumentVersionWithoutContent[],
|
||||
/**
|
||||
* The update ID of the latest document in the response.
|
||||
*/
|
||||
lastUpdateId: bigint, }
|
||||
export interface FetchLatestDocumentsResponse {
|
||||
latestDocuments: DocumentVersionWithoutContent[];
|
||||
/**
|
||||
* The update ID of the latest document in the response.
|
||||
*/
|
||||
lastUpdateId: bigint;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,8 @@ import type { VaultInfo } from "./VaultInfo";
|
|||
/**
|
||||
* Response to listing vaults accessible to the authenticated user.
|
||||
*/
|
||||
export interface ListVaultsResponse { vaults: VaultInfo[], hasMore: boolean, userName: string, }
|
||||
export interface ListVaultsResponse {
|
||||
vaults: VaultInfo[];
|
||||
hasMore: boolean;
|
||||
userName: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,22 +3,23 @@
|
|||
/**
|
||||
* Response to a ping request.
|
||||
*/
|
||||
export interface PingResponse {
|
||||
/**
|
||||
* Semantic version of the server.
|
||||
*/
|
||||
serverVersion: string,
|
||||
/**
|
||||
* Whether the client is authenticated based on the sent Authorization
|
||||
* header.
|
||||
*/
|
||||
isAuthenticated: boolean,
|
||||
/**
|
||||
* List of file extensions that are allowed to be merged.
|
||||
*/
|
||||
mergeableFileExtensions: string[],
|
||||
/**
|
||||
* API version ensuring backwards & forwards compatibility between the client
|
||||
* and server.
|
||||
*/
|
||||
supportedApiVersion: number, }
|
||||
export interface PingResponse {
|
||||
/**
|
||||
* Semantic version of the server.
|
||||
*/
|
||||
serverVersion: string;
|
||||
/**
|
||||
* Whether the client is authenticated based on the sent Authorization
|
||||
* header.
|
||||
*/
|
||||
isAuthenticated: boolean;
|
||||
/**
|
||||
* List of file extensions that are allowed to be merged.
|
||||
*/
|
||||
mergeableFileExtensions: string[];
|
||||
/**
|
||||
* API version ensuring backwards & forwards compatibility between the client
|
||||
* and server.
|
||||
*/
|
||||
supportedApiVersion: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export interface SerializedError { errorType: string, message: string, causes: string[], }
|
||||
export interface SerializedError {
|
||||
errorType: string;
|
||||
message: string;
|
||||
causes: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export interface UpdateTextDocumentVersion { parentVersionId: number, relativePath: string, content: (number | string)[], }
|
||||
export interface UpdateTextDocumentVersion {
|
||||
parentVersionId: number;
|
||||
relativePath: string;
|
||||
content: (number | string)[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,7 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
|
|||
/**
|
||||
* Response to a vault history request (paginated).
|
||||
*/
|
||||
export interface VaultHistoryResponse { versions: DocumentVersionWithoutContent[], hasMore: boolean, }
|
||||
export interface VaultHistoryResponse {
|
||||
versions: DocumentVersionWithoutContent[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,8 @@
|
|||
/**
|
||||
* Summary of a single vault returned by the list-vaults endpoint.
|
||||
*/
|
||||
export interface VaultInfo { name: string, documentCount: number, createdAt: string | null, }
|
||||
export interface VaultInfo {
|
||||
name: string;
|
||||
documentCount: number;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,6 @@
|
|||
import type { CursorPositionFromClient } from "./CursorPositionFromClient";
|
||||
import type { WebSocketHandshake } from "./WebSocketHandshake";
|
||||
|
||||
export type WebSocketClientMessage = { "type": "handshake" } & WebSocketHandshake | { "type": "cursorPositions" } & CursorPositionFromClient;
|
||||
export type WebSocketClientMessage =
|
||||
| ({ type: "handshake" } & WebSocketHandshake)
|
||||
| ({ type: "cursorPositions" } & CursorPositionFromClient);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export interface WebSocketHandshake { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, }
|
||||
export interface WebSocketHandshake {
|
||||
token: string;
|
||||
deviceId: string;
|
||||
lastSeenVaultUpdateId: number | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,6 @@
|
|||
import type { CursorPositionFromServer } from "./CursorPositionFromServer";
|
||||
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
|
||||
|
||||
export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer;
|
||||
export type WebSocketServerMessage =
|
||||
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
|
||||
| ({ type: "cursorPositions" } & CursorPositionFromServer);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||
|
||||
export interface WebSocketVaultUpdate { document: DocumentVersionWithoutContent, }
|
||||
export interface WebSocketVaultUpdate {
|
||||
document: DocumentVersionWithoutContent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,8 +58,10 @@ export class WebSocketManager {
|
|||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
this.resolveDisconnectingPromise = resolve;
|
||||
const { promise, resolve } = Promise.withResolvers<undefined>();
|
||||
this.resolveDisconnectingPromise = (): void => {
|
||||
resolve(undefined);
|
||||
};
|
||||
|
||||
this.isStopped = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ export class SyncClient {
|
|||
private readonly cursorTracker: CursorTracker,
|
||||
private readonly fileChangeNotifier: FileChangeNotifier,
|
||||
private readonly contentCache: FixedSizeDocumentCache,
|
||||
private readonly fileOperations: FileOperations,
|
||||
private readonly serverConfig: ServerConfig,
|
||||
private readonly syncService: SyncService,
|
||||
private readonly persistence: PersistenceProvider<
|
||||
|
|
@ -100,6 +99,13 @@ export class SyncClient {
|
|||
return this.cursorTracker.onRemoteCursorsUpdated;
|
||||
}
|
||||
|
||||
public get hasPendingWork(): boolean {
|
||||
return (
|
||||
this.syncEventQueue.pendingUpdateCount > 0 ||
|
||||
this.webSocketManager.hasOutstandingWork
|
||||
);
|
||||
}
|
||||
|
||||
public static async create({
|
||||
fs,
|
||||
persistence,
|
||||
|
|
@ -219,7 +225,6 @@ export class SyncClient {
|
|||
cursorTracker,
|
||||
fileChangeNotifier,
|
||||
contentCache,
|
||||
fileOperations,
|
||||
serverConfig,
|
||||
syncService,
|
||||
persistence
|
||||
|
|
@ -323,7 +328,7 @@ export class SyncClient {
|
|||
await this.pause();
|
||||
|
||||
this.logger.info("Resetting SyncClient's local state");
|
||||
this.syncEventQueue.clearAllState();
|
||||
await this.syncEventQueue.clearAllState();
|
||||
await this.syncEventQueue.save();
|
||||
this.resetInMemoryState();
|
||||
this.hasFinishedOfflineSync = false;
|
||||
|
|
@ -353,18 +358,14 @@ export class SyncClient {
|
|||
await this.settings.setSettings(value);
|
||||
}
|
||||
|
||||
public syncLocallyCreatedFile(
|
||||
relativePath: RelativePath
|
||||
): void {
|
||||
public syncLocallyCreatedFile(relativePath: RelativePath): void {
|
||||
this.checkIfDestroyed("syncLocallyCreatedFile");
|
||||
|
||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
||||
this.syncer.syncLocallyCreatedFile(relativePath);
|
||||
}
|
||||
|
||||
public syncLocallyDeletedFile(
|
||||
relativePath: RelativePath
|
||||
): void {
|
||||
public syncLocallyDeletedFile(relativePath: RelativePath): void {
|
||||
this.checkIfDestroyed("syncLocallyDeletedFile");
|
||||
|
||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
||||
|
|
@ -387,13 +388,6 @@ export class SyncClient {
|
|||
});
|
||||
}
|
||||
|
||||
public get hasPendingWork(): boolean {
|
||||
return (
|
||||
this.syncEventQueue.pendingUpdateCount > 0 ||
|
||||
this.webSocketManager.hasOutstandingWork
|
||||
);
|
||||
}
|
||||
|
||||
public getDocumentSyncingStatus(
|
||||
relativePath: RelativePath
|
||||
): DocumentSyncStatus {
|
||||
|
|
|
|||
|
|
@ -31,10 +31,7 @@ describe("buildConflictFileName", () => {
|
|||
0,
|
||||
"stem length must be a whole number of families"
|
||||
);
|
||||
assert.ok(
|
||||
!stem.endsWith(""),
|
||||
"stem must not end with a dangling ZWJ"
|
||||
);
|
||||
assert.ok(!stem.endsWith(""), "stem must not end with a dangling ZWJ");
|
||||
});
|
||||
|
||||
it("does not split a base character from its combining mark", () => {
|
||||
|
|
@ -61,7 +58,10 @@ describe("buildConflictFileName", () => {
|
|||
|
||||
describe("CONFLICT_PATH_REGEX", () => {
|
||||
it("does not misclassify user-authored names that start with `conflict-`", () => {
|
||||
assert.strictEqual(CONFLICT_PATH_REGEX.test("conflict-resolution.md"), false);
|
||||
assert.strictEqual(
|
||||
CONFLICT_PATH_REGEX.test("conflict-resolution.md"),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("only inspects the final path segment", () => {
|
||||
|
|
@ -80,6 +80,9 @@ describe("CONFLICT_PATH_REGEX", () => {
|
|||
});
|
||||
|
||||
it("round-trips with buildConflictFileName", () => {
|
||||
assert.strictEqual(CONFLICT_PATH_REGEX.test(buildConflictFileName("note.md")), true);
|
||||
assert.strictEqual(
|
||||
CONFLICT_PATH_REGEX.test(buildConflictFileName("note.md")),
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,16 +8,10 @@
|
|||
export const CONFLICT_PATH_REGEX =
|
||||
/(?:^|\/)conflict-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-[^/]*$/u;
|
||||
|
||||
|
||||
const CONFLICT_PREFIX_LEN = "conflict-".length + 36 + 1;
|
||||
const MAX_SEGMENT_BYTES = 255;
|
||||
const MAX_ORIGINAL_BYTES = MAX_SEGMENT_BYTES - CONFLICT_PREFIX_LEN - 4;
|
||||
|
||||
export function buildConflictFileName(fileName: string): string {
|
||||
const safeName = truncateFileNameToByteLimit(fileName, MAX_ORIGINAL_BYTES);
|
||||
return `conflict-${crypto.randomUUID()}-${safeName}`;
|
||||
}
|
||||
|
||||
function truncateFileNameToByteLimit(
|
||||
fileName: string,
|
||||
maxBytes: number
|
||||
|
|
@ -34,7 +28,9 @@ function truncateFileNameToByteLimit(
|
|||
const extensionBytes = encoder.encode(extension).byteLength;
|
||||
const stemBudget = Math.max(0, maxBytes - extensionBytes);
|
||||
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
const segmenter = new Intl.Segmenter(undefined, {
|
||||
granularity: "grapheme"
|
||||
});
|
||||
let truncatedStem = "";
|
||||
let usedBytes = 0;
|
||||
for (const { segment } of segmenter.segment(stem)) {
|
||||
|
|
@ -45,3 +41,8 @@ function truncateFileNameToByteLimit(
|
|||
}
|
||||
return truncatedStem + extension;
|
||||
}
|
||||
|
||||
export function buildConflictFileName(fileName: string): string {
|
||||
const safeName = truncateFileNameToByteLimit(fileName, MAX_ORIGINAL_BYTES);
|
||||
return `conflict-${crypto.randomUUID()}-${safeName}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export class CursorTracker {
|
|||
[];
|
||||
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
logger: Logger,
|
||||
private readonly queue: SyncEventQueue,
|
||||
private readonly webSocketManager: WebSocketManager,
|
||||
private readonly fileOperations: FileOperations,
|
||||
|
|
@ -82,8 +82,7 @@ export class CursorTracker {
|
|||
for (const clientCursor of this.knownRemoteCursors) {
|
||||
if (
|
||||
clientCursor.documentsWithCursors.some(
|
||||
(document) =>
|
||||
document.relativePath === relativePath
|
||||
(document) => document.relativePath === relativePath
|
||||
)
|
||||
) {
|
||||
clientCursor.upToDateness =
|
||||
|
|
@ -135,7 +134,9 @@ export class CursorTracker {
|
|||
const readContent = await this.fileOperations.read(
|
||||
doc.relativePath
|
||||
);
|
||||
const record = this.queue.getSettledDocumentByPath(doc.relativePath);
|
||||
const record = this.queue.getSettledDocumentByPath(
|
||||
doc.relativePath
|
||||
);
|
||||
if (record?.remoteHash !== (await hash(readContent))) {
|
||||
doc.vaultUpdateId = null;
|
||||
}
|
||||
|
|
@ -221,20 +222,18 @@ export class CursorTracker {
|
|||
private async getDocumentUpToDateness(
|
||||
document: DocumentWithCursors
|
||||
): Promise<DocumentUpToDateness> {
|
||||
const record = this.queue.getSettledDocumentByPath(document.relativePath);
|
||||
const record = this.queue.getSettledDocumentByPath(
|
||||
document.relativePath
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
// the document of the cursor must be from the future
|
||||
return DocumentUpToDateness.Later;
|
||||
}
|
||||
|
||||
if (
|
||||
record.parentVersionId < (document.vaultUpdateId ?? 0)
|
||||
) {
|
||||
if (record.parentVersionId < (document.vaultUpdateId ?? 0)) {
|
||||
return DocumentUpToDateness.Later;
|
||||
} else if (
|
||||
(document.vaultUpdateId ?? 0) < record.parentVersionId
|
||||
) {
|
||||
} else if ((document.vaultUpdateId ?? 0) < record.parentVersionId) {
|
||||
// the document of the cursor must be from the past
|
||||
return DocumentUpToDateness.Prior;
|
||||
}
|
||||
|
|
@ -243,7 +242,9 @@ export class CursorTracker {
|
|||
document.relativePath
|
||||
);
|
||||
|
||||
const currentRecord = this.queue.getSettledDocumentByPath(document.relativePath);
|
||||
const currentRecord = this.queue.getSettledDocumentByPath(
|
||||
document.relativePath
|
||||
);
|
||||
return currentRecord?.remoteHash === (await hash(currentContent))
|
||||
? DocumentUpToDateness.UpToDate
|
||||
: DocumentUpToDateness.Prior;
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ import { FileNotFoundError } from "../errors/file-not-found-error";
|
|||
import type { SyncEventQueue } from "./sync-event-queue";
|
||||
import { removeFromArray } from "../utils/remove-from-array";
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Scans the local filesystem and the document database to determine
|
||||
* which files were created, updated, moved, or deleted while the
|
||||
|
|
@ -20,8 +18,11 @@ export async function scheduleOfflineChanges(
|
|||
operations: FileOperations,
|
||||
queue: SyncEventQueue,
|
||||
enqueueCreate: (path: RelativePath) => void,
|
||||
enqueueUpdate: (args: { oldPath?: RelativePath; relativePath: RelativePath }) => void,
|
||||
enqueueDelete: (path: RelativePath) => void,
|
||||
enqueueUpdate: (args: {
|
||||
oldPath?: RelativePath;
|
||||
relativePath: RelativePath;
|
||||
}) => void,
|
||||
enqueueDelete: (path: RelativePath) => void
|
||||
): Promise<void> {
|
||||
const allLocalFiles = await operations.listFilesRecursively();
|
||||
logger.info(`Scheduling sync for ${allLocalFiles.length} local files`);
|
||||
|
|
@ -30,19 +31,14 @@ export async function scheduleOfflineChanges(
|
|||
const locallyPossiblyDeletedFiles: DocumentWithPath[] = [];
|
||||
|
||||
for (const [path, record] of allDocuments.entries()) {
|
||||
if (
|
||||
record !== undefined
|
||||
) {
|
||||
locallyPossiblyDeletedFiles.push({ path, record });
|
||||
}
|
||||
locallyPossiblyDeletedFiles.push({ path, record });
|
||||
}
|
||||
|
||||
const locallyPossibleCreatedFiles: RelativePath[] = [];
|
||||
const syncedLocalFiles: RelativePath[] = [];
|
||||
|
||||
for (const localFile of allLocalFiles) {
|
||||
if (allDocuments.has(localFile)
|
||||
) {
|
||||
if (allDocuments.has(localFile)) {
|
||||
syncedLocalFiles.push(localFile);
|
||||
} else {
|
||||
locallyPossibleCreatedFiles.push(localFile);
|
||||
|
|
@ -53,19 +49,27 @@ export async function scheduleOfflineChanges(
|
|||
const content = await operations.read(path);
|
||||
const contentHash = await hash(content);
|
||||
|
||||
const matchingDeletedFile = await findMatchingFile(contentHash, locallyPossiblyDeletedFiles);
|
||||
const matchingDeletedFile = await findMatchingFile(
|
||||
contentHash,
|
||||
locallyPossiblyDeletedFiles
|
||||
);
|
||||
if (matchingDeletedFile !== undefined) {
|
||||
logger.debug(
|
||||
`File ${path} might have been moved from ${matchingDeletedFile.path} while offline, scheduling sync to move it`,
|
||||
`File ${path} might have been moved from ${matchingDeletedFile.path} while offline, scheduling sync to move it`
|
||||
);
|
||||
enqueueUpdate({ oldPath: matchingDeletedFile.path, relativePath: path });
|
||||
enqueueUpdate({
|
||||
oldPath: matchingDeletedFile.path,
|
||||
relativePath: path
|
||||
});
|
||||
removeFromArray(locallyPossiblyDeletedFiles, matchingDeletedFile);
|
||||
removeFromArray(locallyPossibleCreatedFiles, path);
|
||||
}
|
||||
}
|
||||
|
||||
for (const path of locallyPossibleCreatedFiles) {
|
||||
logger.debug(`File ${path} was created while offline, scheduling sync to create it`);
|
||||
logger.debug(
|
||||
`File ${path} was created while offline, scheduling sync to create it`
|
||||
);
|
||||
enqueueCreate(path);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,12 @@ import type { DocumentRecord, RelativePath } from "./types";
|
|||
|
||||
function createQueue(ignorePatterns: string[] = []): SyncEventQueue {
|
||||
const logger = new Logger();
|
||||
const settings = new Settings(logger, { ignorePatterns }, async () => { });
|
||||
return new SyncEventQueue(settings, logger, undefined, async () => { });
|
||||
const settings = new Settings(logger, { ignorePatterns }, async () => {
|
||||
/* no-op */
|
||||
});
|
||||
return new SyncEventQueue(settings, logger, undefined, async () => {
|
||||
/* no-op */
|
||||
});
|
||||
}
|
||||
|
||||
function fakeRemoteVersion(
|
||||
|
|
@ -60,9 +64,7 @@ describe("SyncEventQueue", () => {
|
|||
|
||||
const third = await queue.next();
|
||||
assert.strictEqual(third?.type, SyncEventType.LocalDelete);
|
||||
if (third?.type === SyncEventType.LocalDelete) {
|
||||
assert.strictEqual(third.documentId, "A");
|
||||
}
|
||||
assert.strictEqual(third.documentId, "A");
|
||||
|
||||
assert.strictEqual(await queue.next(), undefined);
|
||||
});
|
||||
|
|
@ -74,15 +76,11 @@ describe("SyncEventQueue", () => {
|
|||
|
||||
const first = await queue.next();
|
||||
assert.strictEqual(first?.type, SyncEventType.LocalCreate);
|
||||
if (first?.type === SyncEventType.LocalCreate) {
|
||||
assert.strictEqual(first.path, "a.md");
|
||||
}
|
||||
assert.strictEqual(first.path, "a.md");
|
||||
|
||||
const second = await queue.next();
|
||||
assert.strictEqual(second?.type, SyncEventType.LocalCreate);
|
||||
if (second?.type === SyncEventType.LocalCreate) {
|
||||
assert.strictEqual(second.path, "b.md");
|
||||
}
|
||||
assert.strictEqual(second.path, "b.md");
|
||||
});
|
||||
|
||||
it("delete resolves documentId from path", async () => {
|
||||
|
|
@ -93,14 +91,15 @@ describe("SyncEventQueue", () => {
|
|||
|
||||
const event = await queue.next();
|
||||
assert.strictEqual(event?.type, SyncEventType.LocalDelete);
|
||||
if (event?.type === SyncEventType.LocalDelete) {
|
||||
assert.strictEqual(event.documentId, "A");
|
||||
}
|
||||
assert.strictEqual(event.documentId, "A");
|
||||
});
|
||||
|
||||
it("delete for unknown path is silently ignored", async () => {
|
||||
const queue = createQueue();
|
||||
await queue.enqueue({ type: SyncEventType.LocalDelete, path: "unknown.md" });
|
||||
await queue.enqueue({
|
||||
type: SyncEventType.LocalDelete,
|
||||
path: "unknown.md"
|
||||
});
|
||||
assert.strictEqual(queue.pendingUpdateCount, 0);
|
||||
});
|
||||
|
||||
|
|
@ -112,11 +111,14 @@ describe("SyncEventQueue", () => {
|
|||
|
||||
await queue.setDocument("a.md", fakeRecord("A"));
|
||||
assert.strictEqual(queue.syncedDocumentCount, 1);
|
||||
assert.deepStrictEqual(queue.getSettledDocumentByPath("a.md"), fakeRecord("A"));
|
||||
assert.deepStrictEqual(
|
||||
queue.getSettledDocumentByPath("a.md"),
|
||||
fakeRecord("A")
|
||||
);
|
||||
|
||||
const found = queue.getDocumentByDocumentId("A");
|
||||
assert.strictEqual(found?.path, "a.md");
|
||||
assert.strictEqual(found?.record.documentId, "A");
|
||||
assert.strictEqual(found.record.documentId, "A");
|
||||
|
||||
await queue.removeDocument("a.md");
|
||||
assert.strictEqual(queue.syncedDocumentCount, 0);
|
||||
|
|
@ -127,9 +129,16 @@ describe("SyncEventQueue", () => {
|
|||
const queue = createQueue();
|
||||
await queue.setDocument("a.md", fakeRecord("A"));
|
||||
|
||||
await queue.enqueue({ type: SyncEventType.LocalUpdate, path: "b.md", oldPath: "a.md" });
|
||||
await queue.enqueue({
|
||||
type: SyncEventType.LocalUpdate,
|
||||
path: "b.md",
|
||||
oldPath: "a.md"
|
||||
});
|
||||
assert.strictEqual(queue.getSettledDocumentByPath("a.md"), undefined);
|
||||
assert.strictEqual(queue.getSettledDocumentByPath("b.md")?.documentId, "A");
|
||||
assert.strictEqual(
|
||||
queue.getSettledDocumentByPath("b.md")?.documentId,
|
||||
"A"
|
||||
);
|
||||
});
|
||||
|
||||
it("create can be re-enqueued after being dequeued", async () => {
|
||||
|
|
@ -144,11 +153,20 @@ describe("SyncEventQueue", () => {
|
|||
it("silently ignores create events matching ignore patterns", async () => {
|
||||
const queue = createQueue(["*.tmp", ".hidden/**"]);
|
||||
|
||||
await queue.enqueue({ type: SyncEventType.LocalCreate, path: "scratch.tmp" });
|
||||
await queue.enqueue({ type: SyncEventType.LocalCreate, path: ".hidden/secret.md" });
|
||||
await queue.enqueue({
|
||||
type: SyncEventType.LocalCreate,
|
||||
path: "scratch.tmp"
|
||||
});
|
||||
await queue.enqueue({
|
||||
type: SyncEventType.LocalCreate,
|
||||
path: ".hidden/secret.md"
|
||||
});
|
||||
assert.strictEqual(queue.pendingUpdateCount, 0);
|
||||
|
||||
await queue.enqueue({ type: SyncEventType.LocalCreate, path: "notes-new.md" });
|
||||
await queue.enqueue({
|
||||
type: SyncEventType.LocalCreate,
|
||||
path: "notes-new.md"
|
||||
});
|
||||
assert.strictEqual(queue.pendingUpdateCount, 1);
|
||||
|
||||
await queue.enqueue({
|
||||
|
|
@ -170,7 +188,10 @@ describe("SyncEventQueue", () => {
|
|||
|
||||
assert.strictEqual(queue.pendingUpdateCount, 0);
|
||||
assert.strictEqual(queue.syncedDocumentCount, 1);
|
||||
assert.strictEqual(queue.getSettledDocumentByPath("a.md")?.documentId, "A");
|
||||
assert.strictEqual(
|
||||
queue.getSettledDocumentByPath("a.md")?.documentId,
|
||||
"A"
|
||||
);
|
||||
});
|
||||
|
||||
it("allSettledDocuments returns all tracked documents", async () => {
|
||||
|
|
@ -186,24 +207,39 @@ describe("SyncEventQueue", () => {
|
|||
|
||||
it("loads initial state from persistence", () => {
|
||||
const logger = new Logger();
|
||||
const settings = new Settings(logger, {}, async () => { });
|
||||
const queue = new SyncEventQueue(settings, logger, {
|
||||
documents: [
|
||||
{
|
||||
relativePath: "a.md",
|
||||
...fakeRecord("A", { parentVersionId: 5 })
|
||||
},
|
||||
{
|
||||
relativePath: "b.md",
|
||||
...fakeRecord("B", { parentVersionId: 3 })
|
||||
}
|
||||
],
|
||||
lastSeenUpdateId: 4
|
||||
}, async () => { });
|
||||
const settings = new Settings(logger, {}, async () => {
|
||||
/* no-op */
|
||||
});
|
||||
const queue = new SyncEventQueue(
|
||||
settings,
|
||||
logger,
|
||||
{
|
||||
documents: [
|
||||
{
|
||||
relativePath: "a.md",
|
||||
...fakeRecord("A", { parentVersionId: 5 })
|
||||
},
|
||||
{
|
||||
relativePath: "b.md",
|
||||
...fakeRecord("B", { parentVersionId: 3 })
|
||||
}
|
||||
],
|
||||
lastSeenUpdateId: 4
|
||||
},
|
||||
async () => {
|
||||
/* no-op */
|
||||
}
|
||||
);
|
||||
|
||||
assert.strictEqual(queue.syncedDocumentCount, 2);
|
||||
assert.strictEqual(queue.getSettledDocumentByPath("a.md")?.documentId, "A");
|
||||
assert.strictEqual(queue.getSettledDocumentByPath("b.md")?.documentId, "B");
|
||||
assert.strictEqual(
|
||||
queue.getSettledDocumentByPath("a.md")?.documentId,
|
||||
"A"
|
||||
);
|
||||
assert.strictEqual(
|
||||
queue.getSettledDocumentByPath("b.md")?.documentId,
|
||||
"B"
|
||||
);
|
||||
assert.strictEqual(queue.lastSeenUpdateId, 4);
|
||||
});
|
||||
|
||||
|
|
@ -216,10 +252,16 @@ describe("SyncEventQueue", () => {
|
|||
assert.ok(event?.type === SyncEventType.LocalCreate);
|
||||
const createPromise = event.resolvers.promise;
|
||||
|
||||
await queue.resolveCreate(event, fakeRecord("DOC-1", { parentVersionId: 5 }));
|
||||
await queue.resolveCreate(
|
||||
event,
|
||||
fakeRecord("DOC-1", { parentVersionId: 5 })
|
||||
);
|
||||
|
||||
// Document is now settled
|
||||
assert.strictEqual(queue.getSettledDocumentByPath("a.md")?.documentId, "DOC-1");
|
||||
assert.strictEqual(
|
||||
queue.getSettledDocumentByPath("a.md")?.documentId,
|
||||
"DOC-1"
|
||||
);
|
||||
|
||||
// Promise was resolved
|
||||
assert.strictEqual(await createPromise, "DOC-1");
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import type { Logger } from "../tracing/logger";
|
|||
import { globsToRegexes } from "../utils/globs-to-regexes";
|
||||
import { CONFLICT_PATH_REGEX } from "./conflict-path";
|
||||
import { removeFromArray } from "../utils/remove-from-array";
|
||||
import type { DocumentWithPath } from "./types";
|
||||
import {
|
||||
DocumentWithPath,
|
||||
SyncEventType,
|
||||
type DocumentId,
|
||||
type DocumentRecord,
|
||||
|
|
@ -12,27 +12,28 @@ import {
|
|||
type RelativePath,
|
||||
type StoredSyncState,
|
||||
type SyncEvent,
|
||||
type VaultUpdateId,
|
||||
type VaultUpdateId
|
||||
} from "./types";
|
||||
import { MinCovered } from "../utils/data-structures/min-covered";
|
||||
|
||||
|
||||
export class SyncEventQueue {
|
||||
private _lastSeenUpdateId: MinCovered;
|
||||
|
||||
// Latest state of the filesystem as we know it, excluding
|
||||
// unconfirmed creates but including pending deletes.
|
||||
//
|
||||
// It's always indexed by the latest path on disk.
|
||||
//
|
||||
//
|
||||
// It maps a subset of the remote state onto the local filesystem.
|
||||
private readonly documents = new Map<RelativePath, DocumentRecord>();
|
||||
|
||||
// All outstanding operations in order of occurrence,
|
||||
// can include multiple generations of the same document,
|
||||
// can include multiple generations of the same document,
|
||||
// e.g.: a create, delete, create sequence for the same path.
|
||||
//
|
||||
// The paths within the events must always correspond to the latest
|
||||
// path on disk, so the path of each event may be updated multiple
|
||||
// times.
|
||||
// times.
|
||||
//
|
||||
// It maps pending changes onto the local filesystem.
|
||||
private readonly events: SyncEvent[] = [];
|
||||
|
|
@ -40,8 +41,6 @@ export class SyncEventQueue {
|
|||
// file creations for paths matching any of these patterns will be ignored
|
||||
private ignorePatterns: RegExp[];
|
||||
|
||||
public _lastSeenUpdateId: MinCovered;
|
||||
|
||||
public constructor(
|
||||
private readonly settings: Settings,
|
||||
private readonly logger: Logger,
|
||||
|
|
@ -70,17 +69,13 @@ export class SyncEventQueue {
|
|||
this.documents.set(relativePath, record);
|
||||
}
|
||||
}
|
||||
this._lastSeenUpdateId = new MinCovered(initialState.lastSeenUpdateId ?? 0);
|
||||
this._lastSeenUpdateId = new MinCovered(
|
||||
initialState.lastSeenUpdateId ?? 0
|
||||
);
|
||||
|
||||
this.logger.debug(`Loaded ${this.documents.size} documents and lastSeenUpdateId=${this._lastSeenUpdateId} from storage`);
|
||||
}
|
||||
|
||||
public get lastSeenUpdateId(): VaultUpdateId {
|
||||
return this._lastSeenUpdateId.min;
|
||||
}
|
||||
|
||||
public set lastSeenUpdateId(id: VaultUpdateId) {
|
||||
this._lastSeenUpdateId.add(id);
|
||||
this.logger.debug(
|
||||
`Loaded ${this.documents.size} documents and lastSeenUpdateId=${this._lastSeenUpdateId.min} from storage`
|
||||
);
|
||||
}
|
||||
|
||||
public get pendingUpdateCount(): number {
|
||||
|
|
@ -91,8 +86,19 @@ export class SyncEventQueue {
|
|||
return this.documents.size;
|
||||
}
|
||||
|
||||
public get lastSeenUpdateId(): VaultUpdateId {
|
||||
return this._lastSeenUpdateId.min;
|
||||
}
|
||||
|
||||
public set lastSeenUpdateId(id: VaultUpdateId) {
|
||||
this._lastSeenUpdateId.add(id);
|
||||
}
|
||||
|
||||
public async enqueue(input: FileSyncEvent): Promise<void> {
|
||||
const path = (input.type === SyncEventType.RemoteChange) ? input.remoteVersion.relativePath : input.path;
|
||||
const path =
|
||||
input.type === SyncEventType.RemoteChange
|
||||
? input.remoteVersion.relativePath
|
||||
: input.path;
|
||||
|
||||
if (this.ignorePatterns.some((pattern) => pattern.test(path))) {
|
||||
this.logger.info(
|
||||
|
|
@ -106,22 +112,28 @@ export class SyncEventQueue {
|
|||
return;
|
||||
}
|
||||
|
||||
|
||||
if (input.type === SyncEventType.LocalCreate) {
|
||||
this.events.push({ type: SyncEventType.LocalCreate, path, originalPath: path, resolvers: Promise.withResolvers() });
|
||||
this.events.push({
|
||||
type: SyncEventType.LocalCreate,
|
||||
path,
|
||||
originalPath: path,
|
||||
resolvers: Promise.withResolvers()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const lookupPath = (input.type === SyncEventType.LocalUpdate && input.oldPath) ? input.oldPath : path;
|
||||
const lookupPath =
|
||||
input.type === SyncEventType.LocalUpdate &&
|
||||
input.oldPath !== undefined
|
||||
? input.oldPath
|
||||
: path;
|
||||
const record = this.documents.get(lookupPath);
|
||||
|
||||
// latest creation must take precedence as it's from the doc's latest generation
|
||||
const pendingDocumentId: Promise<DocumentId> | undefined =
|
||||
this.findLatestCreateForPath(lookupPath)?.resolvers.promise;
|
||||
|
||||
const documentId: DocumentId | undefined =
|
||||
record?.documentId;
|
||||
|
||||
const documentId: DocumentId | undefined = record?.documentId;
|
||||
|
||||
if (pendingDocumentId === undefined && documentId === undefined) {
|
||||
// we can get here when deleting a local document after a remote update
|
||||
|
|
@ -129,7 +141,14 @@ export class SyncEventQueue {
|
|||
}
|
||||
|
||||
if (input.type === SyncEventType.LocalDelete) {
|
||||
this.events.push({ type: SyncEventType.LocalDelete, documentId: pendingDocumentId ?? documentId! });
|
||||
const deleteId = pendingDocumentId ?? documentId;
|
||||
if (deleteId === undefined) {
|
||||
throw new Error("Unreachable: deleteId must be defined here");
|
||||
}
|
||||
this.events.push({
|
||||
type: SyncEventType.LocalDelete,
|
||||
documentId: deleteId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -137,30 +156,43 @@ export class SyncEventQueue {
|
|||
if (pendingDocumentId !== undefined) {
|
||||
this.updatePendingCreatePath(input.oldPath, path);
|
||||
} else {
|
||||
if (record === undefined) {
|
||||
throw new Error(
|
||||
"Unreachable: record must be defined for non-pending update"
|
||||
);
|
||||
}
|
||||
this.documents.delete(input.oldPath);
|
||||
this.documents.set(path, record!);
|
||||
this.documents.set(path, record);
|
||||
for (const e of this.events) {
|
||||
// It already has a docId, so there can't be a pending create event for it
|
||||
if (e.type === SyncEventType.LocalUpdate && e.documentId === documentId) {
|
||||
// It already has a docId, so there can't be a pending create event for it
|
||||
if (
|
||||
e.type === SyncEventType.LocalUpdate &&
|
||||
e.documentId === documentId
|
||||
) {
|
||||
e.path = path;
|
||||
}
|
||||
}
|
||||
await this.save();
|
||||
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
this.events.push({ type: SyncEventType.LocalUpdate, documentId: pendingDocumentId ?? documentId!, path, originalPath: path });
|
||||
const updateId = pendingDocumentId ?? documentId;
|
||||
if (updateId === undefined) {
|
||||
throw new Error("Unreachable: updateId must be defined here");
|
||||
}
|
||||
this.events.push({
|
||||
type: SyncEventType.LocalUpdate,
|
||||
documentId: updateId,
|
||||
path,
|
||||
originalPath: path
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async next(): Promise<SyncEvent | undefined> {
|
||||
return this.events.shift();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Call once a create has been acknowledged by the server.
|
||||
*/
|
||||
|
|
@ -170,19 +202,21 @@ export class SyncEventQueue {
|
|||
): Promise<void> {
|
||||
removeFromArray(this.events, event); // in case the create event is still pending
|
||||
await this.setDocument(event.path, record);
|
||||
event.resolvers?.resolve(record.documentId);
|
||||
event.resolvers.resolve(record.documentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the settled document map and persist the new document version.
|
||||
*/
|
||||
public setDocument(path: RelativePath, record: DocumentRecord): Promise<void> {
|
||||
public async setDocument(
|
||||
path: RelativePath,
|
||||
record: DocumentRecord
|
||||
): Promise<void> {
|
||||
this.documents.set(path, record);
|
||||
return this.save();
|
||||
|
||||
}
|
||||
|
||||
public removeDocument(path: RelativePath): Promise<void> {
|
||||
public async removeDocument(path: RelativePath): Promise<void> {
|
||||
this.documents.delete(path);
|
||||
return this.save();
|
||||
}
|
||||
|
|
@ -198,11 +232,7 @@ export class SyncEventQueue {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public getDocumentByDocumentIdOrFail(
|
||||
target: DocumentId
|
||||
): DocumentWithPath {
|
||||
public getDocumentByDocumentIdOrFail(target: DocumentId): DocumentWithPath {
|
||||
const result = this.getDocumentByDocumentId(target);
|
||||
if (!result) {
|
||||
throw new Error(`No document found with id ${target}`);
|
||||
|
|
@ -210,10 +240,6 @@ export class SyncEventQueue {
|
|||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public async save(): Promise<void> {
|
||||
return this.saveData({
|
||||
documents: Array.from(this.documents.entries()).map(
|
||||
|
|
@ -227,16 +253,16 @@ export class SyncEventQueue {
|
|||
}
|
||||
|
||||
// todo: let's remove
|
||||
public getSettledDocumentByPath(path: RelativePath): DocumentRecord | undefined {
|
||||
public getSettledDocumentByPath(
|
||||
path: RelativePath
|
||||
): DocumentRecord | undefined {
|
||||
return this.documents.get(path);
|
||||
}
|
||||
|
||||
|
||||
public allSettledDocuments(): Map<RelativePath, DocumentRecord> {
|
||||
return new Map(this.documents.entries());
|
||||
}
|
||||
|
||||
|
||||
public hasPendingEventsForPath(path: RelativePath): boolean {
|
||||
const record = this.documents.get(path);
|
||||
if (record === undefined) {
|
||||
|
|
@ -252,7 +278,8 @@ export class SyncEventQueue {
|
|||
e.documentId === docId) ||
|
||||
(e.type === SyncEventType.RemoteChange &&
|
||||
// we care about the local path not the remote
|
||||
this.getDocumentByDocumentId(e.remoteVersion.documentId)?.path === path)
|
||||
this.getDocumentByDocumentId(e.remoteVersion.documentId)
|
||||
?.path === path)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -266,11 +293,10 @@ export class SyncEventQueue {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
public async clearAllState(): Promise<void> {
|
||||
this.clearPending();
|
||||
this.documents.clear();
|
||||
this._lastSeenUpdateId.reset()
|
||||
this._lastSeenUpdateId.reset();
|
||||
await this.save();
|
||||
}
|
||||
|
||||
|
|
@ -279,29 +305,6 @@ export class SyncEventQueue {
|
|||
this.events.length = 0;
|
||||
}
|
||||
|
||||
|
||||
private updatePendingCreatePath(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): void {
|
||||
const createEvent = this.findLatestCreateForPath(oldPath);
|
||||
if (createEvent === undefined) return;
|
||||
|
||||
const promise = createEvent.resolvers?.promise;
|
||||
createEvent.path = newPath;
|
||||
|
||||
if (promise !== undefined) {
|
||||
for (const e of this.events) {
|
||||
if (
|
||||
e.type === SyncEventType.LocalUpdate &&
|
||||
e.documentId === promise
|
||||
) {
|
||||
e.path = newPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public findLatestCreateForPath(
|
||||
path: RelativePath
|
||||
): Extract<SyncEvent, { type: SyncEventType.LocalCreate }> | undefined {
|
||||
|
|
@ -314,18 +317,34 @@ export class SyncEventQueue {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
private updatePendingCreatePath(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): void {
|
||||
const createEvent = this.findLatestCreateForPath(oldPath);
|
||||
if (createEvent === undefined) return;
|
||||
|
||||
const { promise } = createEvent.resolvers;
|
||||
createEvent.path = newPath;
|
||||
|
||||
|
||||
|
||||
private rejectAllPendingCreates(): void {
|
||||
for (const event of this.events) {
|
||||
if (event.type === SyncEventType.LocalCreate && event.resolvers !== undefined) {
|
||||
event.resolvers.promise.catch(() => { /* suppressed — consumer may not be listening */ });
|
||||
event.resolvers.reject(new Error("Create was cancelled"));
|
||||
for (const e of this.events) {
|
||||
if (
|
||||
e.type === SyncEventType.LocalUpdate &&
|
||||
e.documentId === promise
|
||||
) {
|
||||
e.path = newPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private rejectAllPendingCreates(): void {
|
||||
for (const event of this.events) {
|
||||
if (event.type === SyncEventType.LocalCreate) {
|
||||
event.resolvers.promise.catch(() => {
|
||||
/* suppressed — consumer may not be listening */
|
||||
});
|
||||
event.resolvers.reject(new Error("Create was cancelled"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import {
|
|||
type DocumentRecord,
|
||||
type SyncEvent,
|
||||
type RelativePath,
|
||||
type VaultUpdateId,
|
||||
type VaultUpdateId
|
||||
} from "./types";
|
||||
import type { Logger } from "../tracing/logger";
|
||||
import { hash } from "../utils/hash";
|
||||
import type { Settings } from "../persistence/settings";
|
||||
import { MoveOnConflict, type FileOperations } from "../file-operations/file-operations";
|
||||
import {
|
||||
MoveOnConflict,
|
||||
type FileOperations
|
||||
} from "../file-operations/file-operations";
|
||||
import { scheduleOfflineChanges } from "./offline-change-detector";
|
||||
import { SyncResetError } from "../errors/sync-reset-error";
|
||||
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
|
||||
|
|
@ -21,9 +24,7 @@ import type { SyncEventQueue } from "./sync-event-queue";
|
|||
import type { SyncService } from "../services/sync-service";
|
||||
import { FileNotFoundError } from "../errors/file-not-found-error";
|
||||
import { HttpClientError } from "../errors/http-client-error";
|
||||
import type {
|
||||
SyncHistory
|
||||
} from "../tracing/sync-history";
|
||||
import type { SyncHistory } from "../tracing/sync-history";
|
||||
import {
|
||||
SyncStatus,
|
||||
SyncType,
|
||||
|
|
@ -79,7 +80,10 @@ export class Syncer {
|
|||
}
|
||||
|
||||
public syncLocallyCreatedFile(relativePath: RelativePath): void {
|
||||
void this.queue.enqueue({ type: SyncEventType.LocalCreate, path: relativePath });
|
||||
void this.queue.enqueue({
|
||||
type: SyncEventType.LocalCreate,
|
||||
path: relativePath
|
||||
});
|
||||
this.ensureDraining();
|
||||
}
|
||||
|
||||
|
|
@ -90,14 +94,18 @@ export class Syncer {
|
|||
oldPath?: RelativePath;
|
||||
relativePath: RelativePath;
|
||||
}): void {
|
||||
void this.queue.enqueue({ type: SyncEventType.LocalUpdate, path: relativePath, oldPath });
|
||||
void this.queue.enqueue({
|
||||
type: SyncEventType.LocalUpdate,
|
||||
path: relativePath,
|
||||
oldPath
|
||||
});
|
||||
this.ensureDraining();
|
||||
}
|
||||
|
||||
public syncLocallyDeletedFile(relativePath: RelativePath): void {
|
||||
void this.queue.enqueue({
|
||||
type: SyncEventType.LocalDelete,
|
||||
path: relativePath,
|
||||
path: relativePath
|
||||
});
|
||||
this.ensureDraining();
|
||||
}
|
||||
|
|
@ -151,7 +159,6 @@ export class Syncer {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
public reset(): void {
|
||||
this._isFirstSyncStarted = false;
|
||||
this.queue.clearPending();
|
||||
|
|
@ -162,20 +169,14 @@ export class Syncer {
|
|||
// fresh scan can only start once the prior one is done.
|
||||
const current = this.runningScheduleSyncForOfflineChanges;
|
||||
if (current !== undefined) {
|
||||
current.finally(() => {
|
||||
if (
|
||||
this.runningScheduleSyncForOfflineChanges ===
|
||||
current
|
||||
) {
|
||||
this.runningScheduleSyncForOfflineChanges =
|
||||
undefined;
|
||||
void current.finally(() => {
|
||||
if (this.runningScheduleSyncForOfflineChanges === current) {
|
||||
this.runningScheduleSyncForOfflineChanges = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private sendHandshakeMessage(): void {
|
||||
const message: WebSocketClientMessage = {
|
||||
type: "handshake",
|
||||
|
|
@ -186,8 +187,6 @@ export class Syncer {
|
|||
this.webSocketManager.sendHandshakeMessage(message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async internalScheduleSyncForOfflineChanges(): Promise<void> {
|
||||
this.isScanning = true;
|
||||
try {
|
||||
|
|
@ -195,10 +194,18 @@ export class Syncer {
|
|||
await this.drainPromise;
|
||||
}
|
||||
await scheduleOfflineChanges(
|
||||
this.logger, this.operations, this.queue,
|
||||
(path) => { this.syncLocallyCreatedFile(path); },
|
||||
(args) => { this.syncLocallyUpdatedFile(args); },
|
||||
(path) => { this.syncLocallyDeletedFile(path); },
|
||||
this.logger,
|
||||
this.operations,
|
||||
this.queue,
|
||||
(path) => {
|
||||
this.syncLocallyCreatedFile(path);
|
||||
},
|
||||
(args) => {
|
||||
this.syncLocallyUpdatedFile(args);
|
||||
},
|
||||
(path) => {
|
||||
this.syncLocallyDeletedFile(path);
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
this.isScanning = false;
|
||||
|
|
@ -207,9 +214,6 @@ export class Syncer {
|
|||
this.ensureDraining();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private ensureDraining(): void {
|
||||
if (this.drainPromise !== undefined) return;
|
||||
if (this.isScanning) return;
|
||||
|
|
@ -218,7 +222,6 @@ export class Syncer {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
private async drain(): Promise<void> {
|
||||
let event = await this.queue.next();
|
||||
while (event !== undefined) {
|
||||
|
|
@ -271,8 +274,10 @@ export class Syncer {
|
|||
`Skipping sync event '${event.type}' because the file no longer exists`
|
||||
);
|
||||
if (event.type === SyncEventType.LocalCreate) {
|
||||
event.resolvers?.promise.catch(() => { });
|
||||
event.resolvers?.reject(new Error("Create was cancelled"));
|
||||
event.resolvers.promise.catch(() => {
|
||||
/* suppressed */
|
||||
});
|
||||
event.resolvers.reject(new Error("Create was cancelled"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -285,10 +290,10 @@ export class Syncer {
|
|||
// promise would otherwise hang forever, blocking any
|
||||
// queued Delete / SyncLocal that `await`s it.
|
||||
if (event.type === SyncEventType.LocalCreate) {
|
||||
event.resolvers?.promise.catch(() => {
|
||||
event.resolvers.promise.catch(() => {
|
||||
/* suppressed */
|
||||
});
|
||||
event.resolvers?.reject(
|
||||
event.resolvers.reject(
|
||||
new Error(
|
||||
`Create was cancelled — server rejected the request (${e.message})`
|
||||
)
|
||||
|
|
@ -300,10 +305,9 @@ export class Syncer {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private async skipIfOversized(event: SyncEvent): Promise<boolean> {
|
||||
let sizeInBytes: number;
|
||||
let relativePath: RelativePath;
|
||||
let sizeInBytes = 0;
|
||||
let relativePath: RelativePath = "";
|
||||
|
||||
switch (event.type) {
|
||||
case SyncEventType.LocalDelete:
|
||||
|
|
@ -316,7 +320,7 @@ export class Syncer {
|
|||
case SyncEventType.RemoteChange:
|
||||
if (event.remoteVersion.isDeleted) return false;
|
||||
sizeInBytes = event.remoteVersion.contentSize;
|
||||
relativePath = event.remoteVersion.relativePath;
|
||||
({ relativePath } = event.remoteVersion);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -329,8 +333,10 @@ export class Syncer {
|
|||
this.history.addHistoryEntry(oversizedEntry);
|
||||
|
||||
if (event.type === SyncEventType.LocalCreate) {
|
||||
event.resolvers?.promise.catch(() => { });
|
||||
event.resolvers?.reject(new Error("Create was cancelled"));
|
||||
event.resolvers.promise.catch(() => {
|
||||
/* suppressed */
|
||||
});
|
||||
event.resolvers.reject(new Error("Create was cancelled"));
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -354,9 +360,6 @@ export class Syncer {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private async processCreate(
|
||||
event: Extract<SyncEvent, { type: SyncEventType.LocalCreate }>
|
||||
): Promise<void> {
|
||||
|
|
@ -378,13 +381,13 @@ export class Syncer {
|
|||
createEvent: event
|
||||
});
|
||||
|
||||
|
||||
this.history.addHistoryEntry({
|
||||
status: SyncStatus.SUCCESS,
|
||||
details: { type: SyncType.CREATE, relativePath: effectivePath },
|
||||
message: response.type === "MergingUpdate"
|
||||
? "Created file and merged with existing remote version"
|
||||
: "Successfully created file on the server",
|
||||
message:
|
||||
response.type === "MergingUpdate"
|
||||
? "Created file and merged with existing remote version"
|
||||
: "Successfully created file on the server",
|
||||
author: response.userId,
|
||||
timestamp: new Date(response.updatedDate)
|
||||
});
|
||||
|
|
@ -393,7 +396,7 @@ export class Syncer {
|
|||
private async processDelete(
|
||||
event: Extract<SyncEvent, { type: SyncEventType.LocalDelete }>
|
||||
): Promise<void> {
|
||||
let documentId = await event.documentId;
|
||||
const documentId = await event.documentId;
|
||||
|
||||
const doc = this.queue.getDocumentByDocumentIdOrFail(documentId);
|
||||
const relativePath = doc.path;
|
||||
|
|
@ -406,7 +409,6 @@ export class Syncer {
|
|||
await this.queue.removeDocument(doc.path);
|
||||
this.queue.lastSeenUpdateId = response.vaultUpdateId;
|
||||
|
||||
|
||||
this.history.addHistoryEntry({
|
||||
status: SyncStatus.SUCCESS,
|
||||
details: {
|
||||
|
|
@ -421,16 +423,16 @@ export class Syncer {
|
|||
private async processLocalUpdate(
|
||||
event: Extract<SyncEvent, { type: SyncEventType.LocalUpdate }>
|
||||
): Promise<void> {
|
||||
let documentId = await event.documentId;
|
||||
const documentId = await event.documentId;
|
||||
|
||||
const { path: diskPath, record } = this.queue.getDocumentByDocumentIdOrFail(documentId);
|
||||
const { path: diskPath, record } =
|
||||
this.queue.getDocumentByDocumentIdOrFail(documentId);
|
||||
|
||||
const contentBytes = await this.operations.read(diskPath);
|
||||
const contentHash = await hash(contentBytes);
|
||||
|
||||
const hashChanged = contentHash !== record.remoteHash;
|
||||
const pathChanged =
|
||||
record.remoteRelativePath !== event.originalPath;
|
||||
const pathChanged = record.remoteRelativePath !== event.originalPath;
|
||||
|
||||
if (!hashChanged && !pathChanged) {
|
||||
this.logger.debug(
|
||||
|
|
@ -443,12 +445,10 @@ export class Syncer {
|
|||
record,
|
||||
relativePath: event.originalPath,
|
||||
contentBytes
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
this.queue.lastSeenUpdateId = response.vaultUpdateId;
|
||||
|
||||
|
||||
await this.handleMaybeMergingResponse({
|
||||
path: diskPath,
|
||||
response,
|
||||
|
|
@ -456,9 +456,7 @@ export class Syncer {
|
|||
originalContentBytes: contentBytes
|
||||
});
|
||||
|
||||
|
||||
const isMerge =
|
||||
"type" in response && response.type === "MergingUpdate";
|
||||
const isMerge = "type" in response && response.type === "MergingUpdate";
|
||||
this.history.addHistoryEntry({
|
||||
status: SyncStatus.SUCCESS,
|
||||
details: {
|
||||
|
|
@ -489,12 +487,12 @@ export class Syncer {
|
|||
// response)
|
||||
createEvent?: Extract<SyncEvent, { type: SyncEventType.LocalCreate }>;
|
||||
}): Promise<void> {
|
||||
let record = {
|
||||
const record = {
|
||||
documentId: response.documentId,
|
||||
parentVersionId: response.vaultUpdateId,
|
||||
remoteRelativePath: response.relativePath
|
||||
};
|
||||
let remoteHash: string;
|
||||
let remoteHash = "";
|
||||
|
||||
if ("type" in response && response.type === "MergingUpdate") {
|
||||
const responseBytes = base64ToBytes(response.contentBase64);
|
||||
|
|
@ -506,11 +504,7 @@ export class Syncer {
|
|||
|
||||
remoteHash = await hash(responseBytes);
|
||||
|
||||
await this.updateCache(
|
||||
response.vaultUpdateId,
|
||||
responseBytes,
|
||||
path
|
||||
);
|
||||
await this.updateCache(response.vaultUpdateId, responseBytes, path);
|
||||
} else {
|
||||
// Fast-forward update: no merge needed
|
||||
remoteHash = contentHash;
|
||||
|
|
@ -524,13 +518,16 @@ export class Syncer {
|
|||
|
||||
if (createEvent === undefined) {
|
||||
// a http response will always be more up-to-date than any queued remote update
|
||||
this.operations.move(path, response.relativePath, MoveOnConflict.EXISTING);
|
||||
await this.operations.move(
|
||||
path,
|
||||
response.relativePath,
|
||||
MoveOnConflict.EXISTING
|
||||
);
|
||||
|
||||
await this.queue.setDocument(response.relativePath, {
|
||||
...record,
|
||||
remoteHash
|
||||
});
|
||||
|
||||
} else {
|
||||
// The response to a create must contain the path from the create request
|
||||
await this.queue.resolveCreate(createEvent, {
|
||||
|
|
@ -542,7 +539,6 @@ export class Syncer {
|
|||
this.queue.lastSeenUpdateId = response.vaultUpdateId;
|
||||
}
|
||||
|
||||
|
||||
private async processRemoteChange(
|
||||
event: Extract<SyncEvent, { type: SyncEventType.RemoteChange }>
|
||||
): Promise<void> {
|
||||
|
|
@ -556,10 +552,16 @@ export class Syncer {
|
|||
// trying to delete a document we've already scheduled for deletion locally
|
||||
return;
|
||||
}
|
||||
return this.processRemoteDelete(documentWithPath.path, remoteVersion);
|
||||
return this.processRemoteDelete(
|
||||
documentWithPath.path,
|
||||
remoteVersion
|
||||
);
|
||||
}
|
||||
|
||||
if (documentWithPath?.record.parentVersionId ?? 0 >= remoteVersion.vaultUpdateId) {
|
||||
if (
|
||||
(documentWithPath?.record.parentVersionId ?? 0) >=
|
||||
remoteVersion.vaultUpdateId
|
||||
) {
|
||||
this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId;
|
||||
this.logger.debug(
|
||||
`Document ${remoteVersion.relativePath} is already up-to-date or has newer local changes; skipping remote update`
|
||||
|
|
@ -569,26 +571,36 @@ export class Syncer {
|
|||
|
||||
if (documentWithPath !== undefined) {
|
||||
// must be the update to an existing doc
|
||||
return this.processRemoteUpdate(documentWithPath.path, documentWithPath.record, remoteVersion);
|
||||
return this.processRemoteUpdate(
|
||||
documentWithPath.path,
|
||||
documentWithPath.record,
|
||||
remoteVersion
|
||||
);
|
||||
}
|
||||
|
||||
const pendingCreate = this.queue.findLatestCreateForPath(remoteVersion.relativePath);
|
||||
const pendingCreate = this.queue.findLatestCreateForPath(
|
||||
remoteVersion.relativePath
|
||||
);
|
||||
|
||||
if (pendingCreate === undefined) {
|
||||
return this.processRemoteCreateForNewDocument(remoteVersion);
|
||||
} else {
|
||||
return this.processRemoteCreateForPendingDocument(remoteVersion, pendingCreate);
|
||||
return this.processRemoteCreateForPendingDocument(
|
||||
remoteVersion,
|
||||
pendingCreate
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async processRemoteDelete(path: RelativePath, remoteVersion: DocumentVersionWithoutContent): Promise<void> {
|
||||
private async processRemoteDelete(
|
||||
path: RelativePath,
|
||||
remoteVersion: DocumentVersionWithoutContent
|
||||
): Promise<void> {
|
||||
await this.operations.delete(path);
|
||||
await this.queue.removeDocument(path);
|
||||
|
||||
this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId;
|
||||
|
||||
|
||||
this.history.addHistoryEntry({
|
||||
status: SyncStatus.SUCCESS,
|
||||
details: {
|
||||
|
|
@ -602,22 +614,29 @@ export class Syncer {
|
|||
});
|
||||
}
|
||||
|
||||
private async processRemoteUpdate(path: RelativePath, record: DocumentRecord, remoteVersion: DocumentVersionWithoutContent): Promise<void> {
|
||||
if (
|
||||
record.parentVersionId >=
|
||||
remoteVersion.vaultUpdateId
|
||||
) {
|
||||
this.logger.debug(
|
||||
`Document ${path} is already up-to-date`
|
||||
);
|
||||
private async processRemoteUpdate(
|
||||
path: RelativePath,
|
||||
record: DocumentRecord,
|
||||
remoteVersion: DocumentVersionWithoutContent
|
||||
): Promise<void> {
|
||||
if (record.parentVersionId >= remoteVersion.vaultUpdateId) {
|
||||
this.logger.debug(`Document ${path} is already up-to-date`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.queue.hasPendingLocalEventsForDocumentId(remoteVersion.documentId)) {
|
||||
if (
|
||||
!this.queue.hasPendingLocalEventsForDocumentId(
|
||||
remoteVersion.documentId
|
||||
)
|
||||
) {
|
||||
// no local changes
|
||||
const currentContent = await this.operations.read(path);
|
||||
const remoteContent = await this.syncService.getDocumentVersionContent({ documentId: remoteVersion.documentId, vaultUpdateId: remoteVersion.vaultUpdateId });
|
||||
this.operations.write(path, currentContent, remoteContent);
|
||||
const remoteContent =
|
||||
await this.syncService.getDocumentVersionContent({
|
||||
documentId: remoteVersion.documentId,
|
||||
vaultUpdateId: remoteVersion.vaultUpdateId
|
||||
});
|
||||
await this.operations.write(path, currentContent, remoteContent);
|
||||
|
||||
await this.updateCache(
|
||||
remoteVersion.vaultUpdateId,
|
||||
|
|
@ -625,20 +644,26 @@ export class Syncer {
|
|||
path
|
||||
);
|
||||
this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId;
|
||||
} // else we don't need to update the content, a subsequent local update will do that
|
||||
|
||||
} // else we don't need to update the content, a subsequent local update will do that
|
||||
|
||||
this.syncRemotelyUpdatedFile({ // schedule it so that the lastSeenUpdateId remains consistent
|
||||
document:
|
||||
remoteVersion
|
||||
})
|
||||
|
||||
void this.syncRemotelyUpdatedFile({
|
||||
// schedule it so that the lastSeenUpdateId remains consistent
|
||||
document: remoteVersion
|
||||
});
|
||||
|
||||
// wait for a local edit to do the actual updating here, so we can't even update the lastSeenUpdateId here
|
||||
const conflictingDoc = this.queue.getSettledDocumentByPath(remoteVersion.relativePath);
|
||||
const actualRelativePath = await this.operations.move(path, remoteVersion.relativePath, conflictingDoc?.parentVersionId ?? 0 < remoteVersion.vaultUpdateId ? MoveOnConflict.EXISTING : MoveOnConflict.NEW);
|
||||
const conflictingDoc = this.queue.getSettledDocumentByPath(
|
||||
remoteVersion.relativePath
|
||||
);
|
||||
const actualRelativePath = await this.operations.move(
|
||||
path,
|
||||
remoteVersion.relativePath,
|
||||
(conflictingDoc?.parentVersionId ?? 0) < remoteVersion.vaultUpdateId
|
||||
? MoveOnConflict.EXISTING
|
||||
: MoveOnConflict.NEW
|
||||
);
|
||||
|
||||
this.queue.setDocument(actualRelativePath, {
|
||||
await this.queue.setDocument(actualRelativePath, {
|
||||
...record,
|
||||
remoteRelativePath: actualRelativePath
|
||||
});
|
||||
|
|
@ -651,22 +676,28 @@ export class Syncer {
|
|||
movedFrom: path
|
||||
},
|
||||
// todo: eh
|
||||
message: `File was renamed remotely from ${path} to ${actualRelativePath}`,
|
||||
message: `File was renamed remotely from ${path} to ${actualRelativePath}`
|
||||
});
|
||||
}
|
||||
|
||||
private async processRemoteCreateForNewDocument(remoteVersion: DocumentVersionWithoutContent): Promise<void> {
|
||||
private async processRemoteCreateForNewDocument(
|
||||
remoteVersion: DocumentVersionWithoutContent
|
||||
): Promise<void> {
|
||||
const remoteContent = await this.syncService.getDocumentVersionContent({
|
||||
documentId: remoteVersion.documentId,
|
||||
vaultUpdateId: remoteVersion.vaultUpdateId
|
||||
});
|
||||
|
||||
const conflictingDoc = this.queue.getSettledDocumentByPath(remoteVersion.relativePath);
|
||||
const conflictingDoc = this.queue.getSettledDocumentByPath(
|
||||
remoteVersion.relativePath
|
||||
);
|
||||
|
||||
const actualPath = await this.operations.create(
|
||||
remoteVersion.relativePath,
|
||||
remoteContent,
|
||||
conflictingDoc?.parentVersionId ?? 0 < remoteVersion.vaultUpdateId ? MoveOnConflict.EXISTING : MoveOnConflict.NEW
|
||||
(conflictingDoc?.parentVersionId ?? 0) < remoteVersion.vaultUpdateId
|
||||
? MoveOnConflict.EXISTING
|
||||
: MoveOnConflict.NEW
|
||||
);
|
||||
|
||||
await this.updateCache(
|
||||
|
|
@ -703,7 +734,10 @@ export class Syncer {
|
|||
// We must avoid duplicating files.
|
||||
private async processRemoteCreateForPendingDocument(
|
||||
remoteVersion: DocumentVersionWithoutContent,
|
||||
pendingCreateEvent: Extract<SyncEvent, { type: SyncEventType.LocalCreate }>
|
||||
pendingCreateEvent: Extract<
|
||||
SyncEvent,
|
||||
{ type: SyncEventType.LocalCreate }
|
||||
>
|
||||
): Promise<void> {
|
||||
const remoteContent = await this.syncService.getDocumentVersionContent({
|
||||
documentId: remoteVersion.documentId,
|
||||
|
|
@ -712,7 +746,9 @@ export class Syncer {
|
|||
const remoteHash = await hash(remoteContent);
|
||||
|
||||
const path = remoteVersion.relativePath;
|
||||
const currentContent = await this.operations.read(pendingCreateEvent.path);
|
||||
const currentContent = await this.operations.read(
|
||||
pendingCreateEvent.path
|
||||
);
|
||||
|
||||
await this.operations.write(path, currentContent, remoteContent);
|
||||
await this.updateCache(
|
||||
|
|
@ -735,25 +771,21 @@ export class Syncer {
|
|||
type: SyncType.UPDATE,
|
||||
relativePath: path
|
||||
},
|
||||
message:
|
||||
`Adopted remote create at ${path}`,
|
||||
message: `Adopted remote create at ${path}`,
|
||||
author: remoteVersion.userId,
|
||||
timestamp: new Date(remoteVersion.updatedDate)
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private async sendUpdate(
|
||||
{ record, relativePath, contentBytes }: {
|
||||
record: DocumentRecord,
|
||||
relativePath: RelativePath,
|
||||
contentBytes: Uint8Array
|
||||
}
|
||||
): Promise<DocumentUpdateResponse> {
|
||||
private async sendUpdate({
|
||||
record,
|
||||
relativePath,
|
||||
contentBytes
|
||||
}: {
|
||||
record: DocumentRecord;
|
||||
relativePath: RelativePath;
|
||||
contentBytes: Uint8Array;
|
||||
}): Promise<DocumentUpdateResponse> {
|
||||
const isText =
|
||||
!isBinary(contentBytes) &&
|
||||
isFileTypeMergable(
|
||||
|
|
@ -783,8 +815,6 @@ export class Syncer {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async updateCache(
|
||||
updateId: VaultUpdateId,
|
||||
contentBytes: Uint8Array,
|
||||
|
|
|
|||
|
|
@ -29,36 +29,41 @@ export enum SyncEventType {
|
|||
LocalCreate = "local-create",
|
||||
LocalUpdate = "local-update", // includes both content and path changes
|
||||
LocalDelete = "local-delete",
|
||||
RemoteChange = "remote-change", // includes every type of create/update/delete coming from the server
|
||||
RemoteChange = "remote-change" // includes every type of create/update/delete coming from the server
|
||||
}
|
||||
|
||||
export type FileSyncEvent =
|
||||
| { type: SyncEventType.LocalCreate; path: RelativePath }
|
||||
| {
|
||||
type: SyncEventType.LocalUpdate; path: RelativePath; oldPath?: RelativePath // oldPath is undefined for content changes
|
||||
}
|
||||
type: SyncEventType.LocalUpdate;
|
||||
path: RelativePath;
|
||||
oldPath?: RelativePath; // oldPath is undefined for content changes
|
||||
}
|
||||
| { type: SyncEventType.LocalDelete; path: RelativePath }
|
||||
| { type: SyncEventType.RemoteChange; remoteVersion: DocumentVersionWithoutContent };
|
||||
| {
|
||||
type: SyncEventType.RemoteChange;
|
||||
remoteVersion: DocumentVersionWithoutContent;
|
||||
};
|
||||
|
||||
export type SyncEvent =
|
||||
| {
|
||||
type: SyncEventType.LocalCreate;
|
||||
path: RelativePath; // current path on disk
|
||||
originalPath: RelativePath; // original path on disk when the event was queued
|
||||
resolvers: PromiseWithResolvers<DocumentId>
|
||||
}
|
||||
type: SyncEventType.LocalCreate;
|
||||
path: RelativePath; // current path on disk
|
||||
originalPath: RelativePath; // original path on disk when the event was queued
|
||||
resolvers: PromiseWithResolvers<DocumentId>;
|
||||
}
|
||||
| {
|
||||
type: SyncEventType.LocalUpdate;
|
||||
documentId: DocumentId | Promise<DocumentId>; // if it's a promise, the promise is fulfilled once the document's create event is processed
|
||||
path: RelativePath; // current path on disk
|
||||
originalPath: RelativePath; // original path on disk when the event was queued
|
||||
// no need to store the old path in case of a rename; the server will figure it out from the parent's path
|
||||
}
|
||||
type: SyncEventType.LocalUpdate;
|
||||
documentId: DocumentId | Promise<DocumentId>; // if it's a promise, the promise is fulfilled once the document's create event is processed
|
||||
path: RelativePath; // current path on disk
|
||||
originalPath: RelativePath; // original path on disk when the event was queued
|
||||
// no need to store the old path in case of a rename; the server will figure it out from the parent's path
|
||||
}
|
||||
| {
|
||||
type: SyncEventType.LocalDelete;
|
||||
documentId: DocumentId | Promise<DocumentId>; // if it's a promise, the promise is fulfilled once the document's create event is processed
|
||||
}
|
||||
type: SyncEventType.LocalDelete;
|
||||
documentId: DocumentId | Promise<DocumentId>; // if it's a promise, the promise is fulfilled once the document's create event is processed
|
||||
}
|
||||
| {
|
||||
type: SyncEventType.RemoteChange;
|
||||
remoteVersion: DocumentVersionWithoutContent;
|
||||
};
|
||||
type: SyncEventType.RemoteChange;
|
||||
remoteVersion: DocumentVersionWithoutContent;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -265,7 +265,7 @@ describe("reset", () => {
|
|||
await sleep(1);
|
||||
|
||||
const secondPromise = locks.withLock(testPath, async () => "second");
|
||||
void secondPromise.catch(() => { }); // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
void secondPromise.catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
locks.reset();
|
||||
|
||||
|
|
@ -286,7 +286,7 @@ describe("reset", () => {
|
|||
await sleep(1);
|
||||
|
||||
const secondPromise = locks.withLock(testPath, async () => "second");
|
||||
void secondPromise.catch(() => { }); // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
void secondPromise.catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
locks.reset();
|
||||
|
||||
|
|
@ -312,7 +312,7 @@ describe("reset", () => {
|
|||
[testPath, testPath2],
|
||||
async () => "multi"
|
||||
);
|
||||
void multiKeyPromise.catch(() => { }); // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
void multiKeyPromise.catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
// Wait for the multi-key operation to acquire testPath and start waiting on testPath2
|
||||
await sleep(10);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { Logger } from "../../tracing/logger";
|
|||
* @template T The type of the key used for locking
|
||||
*/
|
||||
/** Waiter entry with callbacks */
|
||||
interface WaiterEntry<T> {
|
||||
interface WaiterEntry {
|
||||
resolve: () => unknown;
|
||||
reject: (err: unknown) => unknown;
|
||||
}
|
||||
|
|
@ -18,9 +18,12 @@ export class Locks<T> {
|
|||
private readonly locked = new Set<T>();
|
||||
|
||||
/** Queue of waiters for each key */
|
||||
private readonly waiters = new Map<T, WaiterEntry<T>[]>();
|
||||
private readonly waiters = new Map<T, WaiterEntry[]>();
|
||||
|
||||
public constructor(private readonly name: string, private readonly logger?: Logger) { }
|
||||
public constructor(
|
||||
private readonly name: string,
|
||||
private readonly logger?: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Executes a function while holding exclusive locks on one or more keys.
|
||||
|
|
@ -134,7 +137,7 @@ export class Locks<T> {
|
|||
|
||||
waiting.push({
|
||||
resolve,
|
||||
reject,
|
||||
reject
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
export class MinCovered {
|
||||
private seenValues: number[] = [];
|
||||
|
||||
public constructor(private minValue: number) { }
|
||||
public constructor(private minValue: number) {}
|
||||
|
||||
public get min(): number {
|
||||
return this.minValue;
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@ export function logToConsole(
|
|||
): void {
|
||||
logger.onLogEmitted.add((logLine: LogLine) => {
|
||||
const timestamp = logLine.timestamp.toISOString();
|
||||
const {message} = logLine;
|
||||
const { message } = logLine;
|
||||
|
||||
let color = "";
|
||||
let reset = "";
|
||||
if (useColors) {
|
||||
reset = COLORS.reset;
|
||||
({ reset } = COLORS);
|
||||
switch (logLine.level) {
|
||||
case LogLevel.ERROR:
|
||||
color = COLORS.red;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import type { DocumentRecord, DocumentWithPath, RelativePath } from "../sync-operations/types";
|
||||
import type {
|
||||
DocumentRecord,
|
||||
DocumentWithPath,
|
||||
RelativePath
|
||||
} from "../sync-operations/types";
|
||||
import { EMPTY_HASH } from "./hash";
|
||||
|
||||
// TODO: make this smarter so that offline files can be renamed & edited at the same time
|
||||
|
|
@ -6,7 +10,7 @@ export async function findMatchingFile(
|
|||
contentHash: string,
|
||||
candidates: { path: RelativePath; record: DocumentRecord }[]
|
||||
): Promise<DocumentWithPath | undefined> {
|
||||
if (contentHash === await EMPTY_HASH) {
|
||||
if (contentHash === (await EMPTY_HASH)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
export async function hash(content: Uint8Array): Promise<string> {
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
content as Uint8Array<ArrayBuffer>
|
||||
);
|
||||
// Copy into a fresh ArrayBuffer-backed Uint8Array so the buffer type
|
||||
// matches `BufferSource`/`Uint8Array<ArrayBuffer>` expected by digest.
|
||||
const owned = new Uint8Array(content);
|
||||
const digest = await crypto.subtle.digest("SHA-256", owned);
|
||||
const bytes = new Uint8Array(digest);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,14 +44,16 @@ export function rateLimit<
|
|||
newArgs = undefined;
|
||||
}
|
||||
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
const { promise, resolve } = Promise.withResolvers<undefined>();
|
||||
running = promise;
|
||||
sleep(
|
||||
typeof minIntervalMs === "function"
|
||||
? minIntervalMs()
|
||||
: minIntervalMs
|
||||
)
|
||||
.then(resolve)
|
||||
.then(() => {
|
||||
resolve(undefined);
|
||||
})
|
||||
.catch(() => {
|
||||
// sleep cannot fail
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue