Format & lint

This commit is contained in:
Andras Schmelczer 2026-04-25 17:55:46 +01:00
commit 7f62273e72
42 changed files with 2149 additions and 1258 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;
}

Some files were not shown because too many files have changed in this diff Show more