Format & lint

This commit is contained in:
Andras Schmelczer 2026-04-25 17:55:46 +01:00
parent fefac224b0
commit 7f62273e72
179 changed files with 2210 additions and 1319 deletions

View file

@ -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(

View file

@ -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;
}
}

View file

@ -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";

View file

@ -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>());
}
/**

View file

@ -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);
}
}

View file

@ -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[];
}

View file

@ -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[];
}

View file

@ -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[];
}

View file

@ -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[];
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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[];
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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[];
}

View file

@ -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)[];
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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;
}

View file

@ -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);

View file

@ -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;
}

View file

@ -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;

View file

@ -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 {

View file

@ -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
);
});
});

View file

@ -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}`;
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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");

View file

@ -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"));
}
}
}
}

View file

@ -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,

View file

@ -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;
};

View file

@ -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);

View file

@ -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
});
});
}

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View file

@ -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("");
}

View file

@ -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
});