Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,35 @@

All notable changes to this project will be documented in this file.

## Sync Engine v3.1.9 - 2026-09-21

### Core

- Fixed local operation optimization fails to apply due to double local filesystem instantiation.
- Made operation failures retry on abnormal status codes and iOS/macOS specific errors.
- Improved iOS/iPadOS local file streaming to support all file formats.
- Ensured proper cleanup of local file read or write streaming on failure or cancellation.

### Google Drive Module

- Improved small file upload speed by uploading those files in a single multipart request.
- Fixed ineffective error message extraction.
- Ensured proper cleanup of resumable upload sessions on failure or cancellation.

### S3 Module

- Fixed ineffective error handling due to request middleware parameter rewrite.
- Made connection check return server message instead of generic error codes.
- Ensured proper cleanup of multipart upload sessions on failure or cancellation.

### WebDAV Module

- Ensured proper cleanup of Nextcloud-style chunked upload sessions on failure or cancellation.

### Contributors

@kuznetsov-m, @hesprs

## Sync Engine v3.1.8 - 2026-09-19

- Improved iOS/iPadOS local file streaming by ranged requests on supported file formats, instead of relying on the already-broken single `fetch` streaming.
Expand Down
2 changes: 1 addition & 1 deletion docs/src/pages/en/deep-dive/modules/s3.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The module supports:
- Cloudflare R2
- Backblaze B2 through its S3-compatible API
- MinIO
- Garage
- RustFs
- Aliyun / Tencent Cloud object storage
- Wasabi
- Ceph Object Gateway, DigitalOcean Spaces, and other S3-compatible services
Expand Down
9 changes: 5 additions & 4 deletions docs/src/pages/en/deep-dive/request.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ Both are function objects returning promises. They provide a small, middleware-f
`Request` is defined in `packages/plugin/src/modules/Registrar.ts`:

```ts
type Request = (params: RequestParam | string) => Promise<RequestResponse>;
type Request = (url: string, params?: RequestParam) => Promise<RequestResponse>;
```

`RequestParam` follows Obsidian's `RequestUrlParam`, except `body` uses the project's `Binary` (`Uint8Array`) instead of `ArrayBuffer` in Obsidian raw API. A string argument is treated as a `GET` toward this URL.
`RequestParam` follows Obsidian's `RequestUrlParam`, except `body` uses the project's `Binary` (`Uint8Array`) instead of `ArrayBuffer` in Obsidian raw API, and `url` moves from the parameters to the first argument. Omitting `params` performs a plain `GET` toward the URL.

`RequestResponse` is an exported SDK type describing the response returned by `Request`.

Expand Down Expand Up @@ -46,8 +46,9 @@ Remote file-system modules receive `getRequest()` in context, not the base funct
`VaultRequest` is the local counterpart used by `VaultFs`, uses Obsidian vault cache smartly to improve performance. It is a discriminated operation function:

```ts
type VaultRequest = <T extends VaultRequestParam>(
params: T,
type VaultRequest = <T extends VaultRequestParam = { method: 'GET' }>(
key: string,
params?: T,
) => Promise<VaultRequestResponseMap[T['method']]>;
```

Expand Down
2 changes: 2 additions & 0 deletions docs/src/pages/en/deep-dive/sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Cancellation does not roll back completed operations. Task errors raised after c

## Traversal and Glob Matching

After infrastructure initialization, the routine dispatches `syncInitialized` with the run's `Infras` and the compiled matcher.

The routine compiles the configured matcher once, then starts local and remote discovery concurrently. Local traversal calls `localFs.list('/')` with the matcher. Full remote traversal receives a reporter that forwards progress and applies the matcher to each reported path.

The default remote lister performs the full traversal. If the remote root does not exist, it recreates the root, clears records for the local/remote pair, and returns an empty list.
Expand Down
26 changes: 14 additions & 12 deletions docs/src/pages/en/development/debug-and-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ type FsHarness = {
};

type RequestHarness = {
calls: Array<RequestParam | string>;
calls: Array<RequestParam & { url: string }>;
request: Request;
};
```
Expand All @@ -59,21 +59,23 @@ const testKit: {
folder: (key: string) => FolderStat;
flush: (turns?: number) => Promise<void>;
fs: (options?: FsOptions) => FsHarness;
request: (control: Request) => RequestHarness;
request: (
control: (url: string, params: RequestParam) => MaybePromise<Partial<RequestResponse>>,
) => RequestHarness;
stream: (chunks?: Array<Binary | string>) => ReadableStream<Binary>;
};
```

| Helper | Description |
| --------------------- | ---------------------------------------------------------------------------------------- |
| `bytes(value)` | Convert a string to `Binary`. |
| `deferred()` | Create a controlled promise. |
| `file(key, options?)` | Create a `FileStat`. |
| `folder(key)` | Create a `FolderStat`. |
| `flush(turns?)` | Wait for several microtask queues to finish (default 4). |
| `fs(options?)` | Create a stub filesystem. `control` overrides individual methods; `uid` sets `getUid()`. |
| `request(control)` | Wrap a request stub to record calls. |
| `stream(chunks?)` | Create a fake `ReadableStream` from an array. |
| Helper | Description |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `bytes(value)` | Convert a string to `Binary`. |
| `deferred()` | Create a controlled promise. |
| `file(key, options?)` | Create a `FileStat`. |
| `folder(key)` | Create a `FolderStat`. |
| `flush(turns?)` | Wait for several microtask queues to finish (default 4). |
| `fs(options?)` | Create a stub filesystem. `control` overrides individual methods; `uid` sets `getUid()`. |
| `request(control)` | Wrap a response control to record calls. The control receives the same arguments as `Request` and returns response overrides. |
| `stream(chunks?)` | Create a fake `ReadableStream` from an array. |

### `fs()` Details

Expand Down
43 changes: 24 additions & 19 deletions docs/src/pages/en/development/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,28 +39,33 @@ unsubscribe();

`Events` is a merged event map contributed by all internal modules. Every event key and its payload type:

| Event | Payload |
| ---------------------- | ----------------------------------------------------------------------------------- |
| `logSync` | `string` sync log message |
| `logGeneral` | `string` general log message |
| `errorSync` | `string` sync error log message |
| `errorGeneral` | `string` general error log message |
| `moduleLoaded` | `string` module name |
| `moduleUnloaded` | `string` module name |
| `syncStarted` | `{ isCancelled: Ref<boolean>; trigger: string }` |
| `remoteWalkProgress` | `Progress` |
| `syncTerminated` | `SyncTerminateReason` |
| `requestConfirmDelete` | `Array<RemoveLocal>` pending local-remove tasks |
| `requestConfirmTasks` | `Array<BaseTask>` |
| `syncCanceled` | `undefined` (no payload) |
| `taskCompleted` | `TaskInfo` (`{ name: TaskNames; key: string; prettyName: string; isDir: boolean }`) |
| `taskFailed` | `FailedTaskInfo` (`TaskInfo` & `{ error: string }`) |
| `executionStarted` | `Array<BaseTask>` |
| `tasksConfirmed` | `Array<BaseTask>` |
| `deleteConfirmed` | `{ delete: Array<RemoveLocal>; reupload: Array<RemoveLocal> }` |
| Event | Payload |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `logSync` | `string` sync log message |
| `logGeneral` | `string` general log message |
| `errorSync` | `string` sync error log message |
| `errorGeneral` | `string` general error log message |
| `moduleLoaded` | `string` module name |
| `moduleUnloaded` | `string` module name |
| `syncStarted` | `{ isCancelled: Ref<boolean>; trigger: string }` |
| `syncInitialized` | `Infras & { match: (path: string) => GlobMatchResult }`: the run's file systems, record store, and compiled matcher |
| `remoteWalkProgress` | `Progress` |
| `syncTerminated` | `SyncTerminateReason` |
| `requestConfirmDelete` | `Array<RemoveLocal>` pending local-remove tasks |
| `requestConfirmTasks` | `Array<BaseTask>` |
| `syncCanceled` | `undefined` (no payload) |
| `taskCompleted` | `TaskInfo` (`{ name: TaskNames; key: string; prettyName: string; isDir: boolean }`) |
| `taskFailed` | `FailedTaskInfo` (`TaskInfo` & `{ error: string }`) |
| `executionStarted` | `Array<BaseTask>` |
| `tasksConfirmed` | `Array<BaseTask>` |
| `deleteConfirmed` | `{ delete: Array<RemoveLocal>; reupload: Array<RemoveLocal> }` |

::: tip

`syncStarted.isCancelled` is a SynthKernel `Ref<boolean>` — call it as `isCancelled()` to read, or subscribe with `isCancelled.subscribe(...)`.

:::

## Sync Lifecycle Events

`syncStarted` fires before the file-system stacks exist; `syncInitialized` fires once per run after infrastructure initialization and before traversal, and is the only point where the sync's actual `localFs`, `remoteFs`, and `record` are published. Its `Infras` shape is `{ localFs: Fs; remoteFs: Fs; record: RecordStore }`, documented with the [remote lister](./sync#remote-lister); `match` is the compiled [inclusion/exclusion matcher](../usage/settings#inclusion-and-exclusion-rules).
13 changes: 7 additions & 6 deletions docs/src/pages/en/development/request.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Sync Engine has two request systems: `Request` for remote HTTP calls and `VaultR
Remote HTTP request function. Backends receive a composed `Request` instance in their constructor and must use it for all network calls.

```ts
type RequestParam = Omit<RequestUrlParam, 'body'> & {
type RequestParam = Omit<RequestUrlParam, 'body' | 'url'> & {
body?: string | Binary;
ignoreCancellation?: boolean;
};
Expand All @@ -20,10 +20,10 @@ type RequestResponse = {
status: number;
};

type Request = (params: RequestParam | string) => Promise<RequestResponse>;
type Request = (url: string, params?: RequestParam) => Promise<RequestResponse>;
```

`RequestParam` extends Obsidian's `RequestUrlParam` (minus `body`) with a `body` field accepting `string | Binary`. Passing a plain string instead of a `RequestParam` object uses it as the URL.
`RequestParam` extends Obsidian's `RequestUrlParam` (minus `body` and `url`) with a `body` field accepting `string | Binary`. The URL is always the first argument; omitting `params` performs a plain `GET`.
`RequestResponse` is an exported SDK type for the response returned by `Request`.

Set `ignoreCancellation` to `true` to let a request through after the sync has been cancelled. Reserve it for cleanup calls that release remote resources the backend already created, such as aborting an incomplete multipart upload.
Expand All @@ -44,10 +44,11 @@ type VaultRequestParam = (
| { method: 'EXISTS' }
| { method: 'STAT'; cached?: boolean }
| { method: 'LIST'; cached?: boolean }
) & { key: string; ignoreCancellation?: boolean };
) & { ignoreCancellation?: boolean };

type VaultRequest = <T extends VaultRequestParam>(
params: T,
type VaultRequest = <T extends VaultRequestParam = { method: 'GET' }>(
key: string,
params?: T,
) => Promise<VaultRequestResponseMap[T['method']]>;
```

Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "sync-engine",
"name": "Sync Engine",
"version": "3.1.8",
"version": "3.1.9",
"minAppVersion": "1.13.0",
"authorUrl": "https://hesprs.github.io",
"description": "The extensible vault synchronization engine: Fast · Free · Reliable. Supports WebDAV, S3, and Google Drive.",
Expand Down
12 changes: 6 additions & 6 deletions modules.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,31 @@
{
"id": "webdav",
"name": "WebDAV",
"version": "0.1.17",
"version": "0.1.18",
"description": "WebDAV backend support.",
"icon": "server",
"main": "https://sync.consensia.cc/modules/webdav.js",
"minPluginVersion": "3.1.0",
"minPluginVersion": "3.1.9",
"readme": "https://sync.consensia.cc/deep-dive/modules/webdav"
},
{
"id": "s3",
"name": "S3",
"version": "0.1.5",
"version": "0.1.6",
"description": "S3 and S3-compatible backend support.",
"icon": "server",
"main": "https://sync.consensia.cc/modules/s3.js",
"minPluginVersion": "3.1.5",
"minPluginVersion": "3.1.9",
"readme": "https://sync.consensia.cc/deep-dive/modules/s3"
},
{
"id": "gdrive",
"name": "Google Drive",
"version": "0.0.6",
"version": "0.1.0",
"description": "Google Drive backend support.",
"icon": "server",
"main": "https://sync.consensia.cc/modules/gdrive.js",
"minPluginVersion": "3.1.5",
"minPluginVersion": "3.1.9",
"readme": "https://sync.consensia.cc/deep-dive/modules/gdrive"
},
{
Expand Down
21 changes: 13 additions & 8 deletions packages/gdrive/src/gdrive/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export type DriveFileList = {
nextPageToken?: string;
};

type DriveError = {
error?: { code?: number; message?: string } | string;
error_description?: string;
};

const mtimeMissing = new Error('Google Drive did not return the modified time for a file!');

/** Escapes a string literal used inside a Drive `q` search expression. */
Expand All @@ -48,14 +53,14 @@ export function getHeader(
}

export function parseDriveError(response: RequestResponse): string | undefined {
const parsed = response as {
error?: { code?: number; message?: string } | string;
error_description?: string;
};
if (typeof parsed.error === 'string')
return `Google Drive ${parsed.error}: ${parsed.error_description ?? ''}`;
if (parsed.error?.message)
return `Google Drive ${parsed.error.code ?? response.status}: ${parsed.error.message}`;
try {
const { error, error_description } = response.json<DriveError>();
if (typeof error === 'string') return `Google Drive ${error}: ${error_description ?? ''}`;
if (error?.message)
return `Google Drive ${error.code ?? response.status}: ${error.message}`;
} catch {
// Non-JSON error body (e.g. empty 503 responses).
}
}

export function toFileStat(key: string, file: DriveFile): FileStat {
Expand Down
12 changes: 7 additions & 5 deletions packages/gdrive/src/gdrive/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Request, RequestParam } from '@hesprs/sync-engine-sdk';
import { getStatus } from '@repo/shared/get-status';
import type { Request } from '@hesprs/sync-engine-sdk';
import { getStatus } from '@repo/shared/error';
import { Platform, requestUrl, SecretStorage } from 'obsidian';
import {
buildUrl,
Expand Down Expand Up @@ -243,10 +243,12 @@ export class TokenManager {

/** Injects the bearer token into every remote request and retries once on 401. */
export function bearerMiddleware(request: Request, manager: TokenManager): Request {
return async (params) => {
const base: RequestParam = typeof params === 'string' ? { url: params } : params;
return async (url, params) => {
const send = (token: string) =>
request({ ...base, headers: { ...base.headers, Authorization: `Bearer ${token}` } });
request(url, {
...params,
headers: { ...params?.headers, Authorization: `Bearer ${token}` },
});
try {
return await send(await manager.getToken());
} catch (error: unknown) {
Expand Down
6 changes: 3 additions & 3 deletions packages/gdrive/src/gdrive/check-connection.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import type { CheckConnectionResult, Request } from '@hesprs/sync-engine-sdk';
import { getMessage } from '@repo/shared/error';
import { DRIVE_API, buildUrl, parseDriveError } from './api';

export default async function checkConnection(request: Request): Promise<CheckConnectionResult> {
try {
const response = await request({
const response = await request(buildUrl(DRIVE_API, '/about', { fields: 'storageQuota' }), {
method: 'GET',
throw: false,
url: buildUrl(DRIVE_API, '/about', { fields: 'storageQuota' }),
});
if (response.status >= 200 && response.status < 300) return { success: true } as const;
return {
reason: parseDriveError(response) ?? `HTTP ${response.status}`,
success: false,
} as const;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorMessage = getMessage(error);
return { reason: errorMessage, success: false } as const;
}
}
Loading
Loading