Compare commits

...

17 Commits

Author SHA1 Message Date
github-actions[bot]
1bb50c1580 Releasing 1.0.11 2026-08-09 14:49:28 +00:00
vorotamoroz
d17330f3c7 Merge pull request #1094 from vrtmrz/fix/issue-1020-fast-fetch-transport
Fall back to Standard Fetch for CouchDB's internal request API
2026-08-09 23:29:40 +09:00
vorotamoroz
bd45924649 Use Commonlib 0.1.10 for buffered Fast Fetch fallback 2026-08-09 12:52:03 +00:00
vorotamoroz
b1ad3e0653 docs: define Fast Fetch transport eligibility 2026-08-09 11:37:14 +00:00
vorotamoroz
6b94f0ce47 Merge pull request #1039 from andrewleech/fix/float-mtime-mobile-crash
fix: prevent float mtime from crashing mobile clients
2026-08-09 19:04:35 +09:00
vorotamoroz
08b55b7677 docs: note fractional timestamp fix 2026-08-09 09:14:54 +00:00
vorotamoroz
21d904cfd6 fix(storage): adapt timestamp guard to current Commonlib
Use the packaged Commonlib type import and add regression coverage for every Obsidian vault and storage write method. Verify that fractional timestamps are floored without mutating caller-owned options.
2026-08-09 08:46:21 +00:00
vorotamoroz
00de35e5d4 Merge current main into PR 1039 2026-08-09 08:45:16 +00:00
vorotamoroz
f2976bc89a Merge pull request #1093 from vrtmrz/1_0_10
Releasing 1.0.10
2026-08-09 13:19:50 +09:00
github-actions[bot]
b65deede79 Releasing 1.0.10 2026-08-09 02:50:06 +00:00
vorotamoroz
9203bdd40e Merge pull request #1092 from vrtmrz/update/commonlib-0.1.9
Update Commonlib to 0.1.9
2026-08-09 11:44:44 +09:00
vorotamoroz
fcd30d07be Update Commonlib to 0.1.9 2026-08-09 02:35:57 +00:00
vorotamoroz
cfb75a05db Merge pull request #1091 from vrtmrz/fix/issue-reporting-guide
Refresh issue reporting guidance
2026-08-09 00:36:16 +09:00
vorotamoroz
5b19f4415d Refresh issue reporting guidance 2026-08-08 15:10:54 +00:00
vorotamoroz
047429033f Merge pull request #1090 from vrtmrz/1_0_9
Releasing 1.0.9
2026-08-08 22:43:59 +09:00
Andrew Leech
a3a09df3c8 fix(storage): floor write-option timestamps in Obsidian adapters
Obsidian's mobile storage layer forwards mtime/ctime to Capacitor's
Filesystem.setTimes, whose native binding casts the value to a Java Long.
A non-integer (float) timestamp makes that cast throw (ClassCastException:
Double cannot be cast to Long), which crashes the app on launch as soon as
such a document is replicated in.

Float timestamps can reach the database from any client that stores
fs.Stats.mtimeMs without flooring. Coerce mtime/ctime to integer ms at the
Obsidian vault and storage adapter write boundary, so a float already
present in the mesh can't crash the app regardless of where it came from.

The truly central choke point is dbToStorage in livesync-commonlib; a
matching guard there would cover the CLI and webapp too. This change
protects the platform that actually crashes.

Claude-Session: https://claude.ai/code/session_0123E9jVQrsgu3zb82Csuwhi
2026-07-22 14:08:21 +10:00
Andrew Leech
4393a49cba fix(cli): floor watch-mode stat timestamps to integer ms
CLIWatchAdapter._toNodeFile passed chokidar's raw stats.ctimeMs/mtimeMs
straight through. On Linux those carry sub-millisecond precision (e.g.
1778511180024.462), so watch-mode file changes wrote non-integer
timestamps into the database.

The earlier floor fix (3f7bb047) covered the scan/stat adapters but missed
this watch path, which is the one the daemon actually uses at runtime.
Mobile clients then crash on such a document because Capacitor's
Filesystem.setTimes casts the value to a Java Long (ClassCastException:
Double cannot be cast to Long).

Floor ctimeMs/mtimeMs here, with a null guard so a partial stat still
falls back to Date.now(). Adds a regression test that a fractional stat is
floored.

Claude-Session: https://claude.ai/code/session_0123E9jVQrsgu3zb82Csuwhi
2026-07-22 14:08:21 +10:00
19 changed files with 289 additions and 146 deletions

View File

@@ -8,7 +8,7 @@ assignees: ''
---
Thank you for taking the time to report this issue!
Before filling in this form, please read: [How to report an issue](../docs/to_issue_reporting.md).
Before filling in this form, please read [How to report an issue](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/to_issue_reporting.md).
Issues with sufficient information will be prioritised.
@@ -49,13 +49,14 @@ To get it: open the command palette → "Show debug info".
</details>
### LiveSync version
The hatch report (below) includes version information. If you cannot provide the report, please fill in the version here.
The full LiveSync report below includes version information. If you cannot provide the report, please fill in the version here.
- Self-hosted LiveSync version: <!-- e.g. 0.23.0 — find it in Obsidian Settings → Community Plugins -->
- Self-hosted LiveSync version: <!-- Find it in Obsidian Settings → Community plugins. -->
### Report and Logs from LiveSync
Perform a `Generate full report for opening the issue with debug info` command and provide the generated report. This contains detailed information and recent 1000 log lines, which is very helpful for debugging. **PLEASE AMEND THE REPORT TO REMOVE ANY SENSITIVE INFORMATION BEFORE PASTING.**
If too large to paste here, upload to [Gist](https://gist.github.com/) and share the link.
Run `Generate full report for opening the issue with debug info` and provide the generated report. It contains detailed information and up to 1,000 recent log lines. Review the complete output, and remove credentials, private remote details, Vault names, file paths, file contents, and other private information before sharing it.
If the report is too large to paste here, upload the redacted report to [Gist](https://gist.github.com/) and share the link.
<details>
<summary>Report and Logs (primary)</summary>

View File

@@ -0,0 +1,78 @@
# Architectural Decision Record: Fast Fetch Transport Eligibility
## Status
Accepted
## Context
Fast Fetch accelerates Fast Setup (Simple Fetch) by reading bounded pages from
CouchDB's continuous changes feed. It consumes each response incrementally,
persists documents while the page is still arriving, and cancels the underlying
request when the page completes or fails.
The CouchDB setting `useRequestAPI`, labelled 'Use Internal API', routes ordinary
replication through Obsidian's `requestUrl` API to avoid browser CORS
restrictions. This API exposes a completed response as text, JSON, or an
`ArrayBuffer`; it does not expose the network response progressively or accept
the Fetch API's `AbortSignal`. Wrapping its result in a `Response` does not
restore those transport properties.
Custom headers can cause a browser preflight, and an authenticating proxy may
reject that preflight before the requested header values are sent. Custom
headers do not, however, make Fast Fetch intrinsically incompatible. A server
with correctly configured CORS can accept the same headers through the ordinary
Fetch API and retain streaming behaviour.
Ordinary PouchDB replication has a different response contract. Standard Fetch
uses finite batches, while LiveSync uses long-poll responses whose change
payload is bounded by the replication batch size. Both can process each response
after it has completed and do not depend on progressively reading a
document-bearing continuous feed.
## Decision
Fast Fetch requires a Fetch-compatible transport which exposes the response
body progressively and honours request cancellation.
When `useRequestAPI` is enabled for a CouchDB remote, Fast Fetch falls back to
Standard Fetch before entering the Fast Fetch activity or resetting the local
database through the Fast Fetch path. The presence of custom headers alone does
not disable Fast Fetch. Once Standard Fetch resets the local database, it
invalidates any retained Fast Fetch checkpoint for that database.
Commonlib's Rebuilder owns this eligibility decision because it owns both Fast
Fetch and the existing Standard Fetch fallback. The streaming implementation
does not receive Obsidian's buffered request adapter, and LiveSync does not add
proxy-specific or Cloudflare-specific policy.
## Consequences
- Initial retrieval through Standard Fetch may be slower and issue more HTTP
requests because PouchDB uses the configured batch size, document retrieval,
and checkpoint operations. The decision does not assume that `requestUrl` is
faster; its benefit here is compatibility with connections which browser CORS
would otherwise reject.
- LiveSync remains supported with `useRequestAPI`. Its HTTP adapter uses
long-poll responses whose change payload is bounded by the replication batch
size, rather than the document-bearing stream required by Fast Fetch.
- A user whose server accepts the configured custom headers through correct
CORS handling can leave `useRequestAPI` disabled and continue to use Fast
Fetch.
- The decision can be revisited if Obsidian provides a progressively readable,
cancellable internal request API, or if a separately designed buffered
transport establishes explicit payload bounds and equivalent cancellation
semantics.
## Verification
Commonlib unit tests verify that `useRequestAPI` selects only the existing
Standard Fetch activity, does not invoke Streaming Fetch, and invalidates any
retained Fast Fetch checkpoint after the local database is reset. Existing
tests continue to verify that custom headers are passed to Fast Fetch when
`useRequestAPI` is disabled.
## References
- [Fast Fetch Persistence and Completion Semantics](2026_08_fast_fetch_persistence_and_completion.md)
- [Apache CouchDB changes-feed API](https://docs.couchdb.org/en/stable/api/database/changes.html)

View File

@@ -1,145 +1,65 @@
# How to report an issue
Thank you for helping improve Self-hosted LiveSync!
Thank you for helping improve Self-hosted LiveSync. A concise report with the right evidence is more useful than trying several recovery operations before reporting the original symptom.
This document explains how to collect the information needed for an issue report. Issues with sufficient information will be prioritised.
Use the [issue report template](https://github.com/vrtmrz/obsidian-livesync/issues/new?template=issue-report.md) for the report itself. Use [Troubleshooting](troubleshooting.md) to diagnose a symptom or choose a recovery action.
---
## Preserve the original symptom
## Filled example
Do not reset a database, rebuild a remote, change transport, or enable P2P merely to see whether the problem disappears. These actions can change the evidence and may make the original cause harder to identify.
Here is an example of a well-filled report for reference.
If the problem may involve data loss, corruption, or unexpected deletion, preserve a copy of every readable affected file and stop editing it on other devices before changing settings.
### Abstract
Include when the problem began, whether it followed an update or restart, how often it occurs, and which device and remote type were involved.
The synchronisation hung up immediately after connecting.
## Required information
### Expected behaviour
### Describe the behaviour
- Synchronisation ends with the message `Replication completed`
- Everything synchronised
Complete the issue template with:
### Actually happened
- a one- or two-sentence summary;
- the expected and actual behaviour;
- repeatable steps, or the frequency and timing when reliable reproduction is not available; and
- the role of each relevant device, such as the device where the change originated and the device where the failure appeared.
- Synchronisation was cancelled with the message `TypeError: Failed to fetch` (visible in the plug-in log around lines 1012)
- No files synchronised
### Obsidian debug information
### Reproducing procedure
Open the command palette with `Ctrl`+`P` or `Command`+`P`, run `Show debug info`, and include its output for each relevant device. The device where the problem appeared is required. Information from the other participating devices is particularly useful for synchronisation problems.
1. Configure LiveSync with the settings shown in the attached report.
2. Click the sync button on the ribbon.
3. Synchronisation begins.
4. About two or three seconds later, the error `TypeError: Failed to fetch` appears.
5. Replication stops. No files synchronised.
### Full LiveSync report
### Obsidian debug info (Device 1 — Windows desktop)
Run `Generate full report for opening the issue with debug info` on the device where the problem appeared. For a synchronisation problem, also collect a report from another participating device when its settings or logs are relevant. The command copies the current LiveSync settings summary and up to 1,000 recent log lines. It collects verbose log lines even when `Verbose Log` is disabled, so you do not need to enable that setting before reproducing the problem.
```
SYSTEM INFO:
Obsidian version: v1.2.8
Installer version: v1.1.15
Operating system: Windows 10 Pro 10.0.19044
Login status: logged in
Catalyst license: supporter
Insider build toggle: off
Community theme: Minimal v6.1.11
Snippets enabled: 3
Restricted mode: off
Plugins installed: 35
Plugins enabled: 11
1: Self-hosted LiveSync v0.19.4
...
```
The command automatically redacts known credential fields in the settings summary. It cannot guarantee that private text in log messages or unrecognised configuration fields is removed. Review the complete output before sharing it. Remove or replace:
### Report from LiveSync
- usernames, passwords, passphrases, tokens, keys, and custom headers;
- private server URLs, network addresses, database names, bucket names, room identifiers, and relay details;
- Vault names, device names, and file paths; and
- file contents or other private text which appears in a log message.
```
----remote config----
cors:
credentials: "true"
...
---- Plug-in config ---
couchDB_URI: self-hosted
couchDB_USER: 𝑅𝐸𝐷𝐴𝐶𝑇𝐸𝐷
...
```
Document and chunk identifiers can also be private metadata, but they may be necessary for diagnosing file reconstruction and chunk availability. Decide deliberately whether to share them. If you remove them, state that the report was redacted and that this may limit the diagnosis.
### Plug-in log
For a large report, you may share a GitHub Gist after reviewing and redacting it. Deleting a Gist later cannot undo information which has already been disclosed.
```
2023/5/24 10:50:33->HTTP:GET to:/ -> failed
2023/5/24 10:50:33->TypeError:Failed to fetch
2023/5/24 10:50:33->could not connect to https://example.com/ : your vault
(TypeError:Failed to fetch)
```
## Additional evidence when relevant
---
### A problem involving one file
## How to collect each piece of information
Run `Copy database information for the active file`, or use **Hatch****Copy database information for a file** to select another file.
### Obsidian debug info
This report describes only the local database on that device. It includes the Vault-relative path, document and chunk identifiers, local revisions, conflicts, and local chunk availability. It does not query the remote or include file contents. Review paths and identifiers as private metadata before sharing them.
Open the command palette (`Ctrl/Cmd + P`) and run **"Show debug info"**. Copy the output and paste it into the issue.
### A problem which crosses a restart
If multiple devices are involved in the problem (e.g., sync between a phone and a desktop), please provide the debug info for each device. The device where the issue occurred is required; information from other devices is strongly recommended.
Use `Write logs into the file` under **Hatch** only when the in-memory report cannot cover the restart. Persistent logging affects performance and can record private information. Disable it after reproducing the problem, review the log before sharing it, and remove the log file when it is no longer needed.
### Report from LiveSync (hatch report)
### A connection, authentication, or CORS problem
1. Open LiveSync settings.
2. Go to the **Hatch** pane.
3. Press the **Make report** button.
Include network evidence only when the ordinary LiveSync log cannot show the rejected response. Follow [Inspect a network failure](troubleshooting.md#inspect-a-network-failure), and remove request paths, remote addresses, authority and authorisation values, cookies, credentials, payload identifiers, and response secrets before sharing screenshots or copied data.
The report will be copied to your clipboard. It contains your LiveSync configuration and the remote server configuration, with credentials automatically redacted.
## Sharing the report
**Tip:** For large reports, consider uploading to [GitHub Gist](https://gist.github.com/) and sharing the link instead of pasting directly into the issue. This makes it easier to manage, and if you accidentally leave sensitive data in, a Gist can be deleted.
Paste reports into the matching collapsible sections in the issue template, or provide a link to an already-redacted Gist. A separate plug-in log is normally unnecessary because the full LiveSync report already contains the recent verbose log history.
If you paste directly, wrap it in a `<details>` tag to keep the issue readable:
```
<details>
<summary>Report from hatch</summary>
```
----remote config----
:
```
</details>
```
### Plug-in log
The plug-in log is volatile by default (not saved to disk) and shown only in the log dialogue, which can be opened by tapping the **document box icon** in the ribbon.
#### Enable verbose log
Before reproducing the issue, enable **Verbose Log** in LiveSync's **General Settings** pane. Without this, many diagnostic messages will be suppressed.
#### Persist the log to a file (optional)
If you need to capture a log across a restart, enable **"Write logs into the file"** in General Settings. Note that log files may contain sensitive information — use this option only for troubleshooting, and disable it afterwards.
As with the hatch report, consider uploading large logs to [GitHub Gist](https://gist.github.com/).
### Network log (for connection-related issues only)
If the issue is related to network connectivity (e.g., cannot connect to the server, authentication errors), a network log captured from browser DevTools can be very helpful. You do not need to include this for non-connection issues.
#### Opening DevTools
| Platform | Shortcut |
|----------|----------|
| Windows / Linux | `Ctrl + Shift + I` |
| macOS | `Cmd + Shift + I` |
| Android | Use [Chrome remote debugging](https://developer.chrome.com/docs/devtools/remote-debugging/) |
| iOS | Use [Safari Web Inspector](https://developer.apple.com/documentation/safari-developer-tools/inspecting-ios) on a Mac |
#### What to capture
1. Open the **Network** pane in DevTools.
2. Reproduce the issue.
3. Look for requests marked in red.
4. Capture screenshots of the **Headers**, **Payload**, and **Response** tabs for those requests.
**Important — redact before sharing:**
- Headers: conceal the request URL path, Remote Address, `authority`, and `authorisation` values.
- Payload / Response: the `_id` field contains your file paths — redact if needed.
If a maintainer asks for a more specialised diagnostic, collect only that additional evidence and review it again before publishing it.

View File

@@ -117,6 +117,8 @@ Enable Obsidian's `Detect all file extensions`, then check LiveSync selectors, i
## Collect a report
Follow [How to report an issue](to_issue_reporting.md) for the complete reporting checklist, including Obsidian debug information and the privacy review required before sharing evidence.
Run `Generate full report for opening the issue with debug info` to copy the current settings summary and recent verbose log lines. Remove credentials, remote URLs, Vault names, file contents, and other private information before sharing it.
When a problem concerns one file, run **Copy database information for the active file**, or use **Hatch****Copy database information for a file** to select another file. The report describes this device's local database view, including the Vault-relative path, document and chunk identifiers, local database revisions, conflicts, and local chunk availability. It does not query the remote server or include file contents. Treat paths and identifiers as private metadata before sharing.

View File

@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "1.0.9",
"version": "1.0.11",
"minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz",

18
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "1.0.9",
"version": "1.0.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "1.0.9",
"version": "1.0.11",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -23,7 +23,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.8",
"@vrtmrz/livesync-commonlib": "0.1.10",
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
@@ -4775,9 +4775,9 @@
}
},
"node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.8.tgz",
"integrity": "sha512-Kn1AF41h2Dog37ThU7KgLcKxItCCerLEBWg1eSGAUoTk3TyPBYynvtmVFwWhy6LCePcuwB/+x7EQNTL3Sgzkyg==",
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.10.tgz",
"integrity": "sha512-1t1e8EPM2fuIbC107jJQXbSivWXcspD7kR/3AOgT29O9E5UbGFZR6vj1f84D6vOe8BiHhv8Ng9GvrCV6U+gGXg==",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.808.0",
@@ -15924,7 +15924,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "1.0.9-cli",
"version": "1.0.11-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -15949,7 +15949,7 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "1.0.9-webapp",
"version": "1.0.11-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.52"
},
@@ -15961,7 +15961,7 @@
}
},
"src/apps/webpeer": {
"version": "1.0.9-webpeer",
"version": "1.0.11-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.52"
},

View File

@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "1.0.9",
"version": "1.0.11",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
@@ -177,7 +177,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.8",
"@vrtmrz/livesync-commonlib": "0.1.10",
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",

View File

@@ -121,8 +121,10 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
return {
path: path.relative(this.basePath, filePath).replace(/\\/g, "/") as FilePath,
stat: {
ctime: stats?.ctimeMs ?? Date.now(),
mtime: stats?.mtimeMs ?? Date.now(),
// Floor to integer milliseconds; Linux fs.Stats.*Ms carry sub-millisecond
// precision, and timestamps are stored as integer ms everywhere else.
ctime: Math.floor(stats?.ctimeMs ?? Date.now()),
mtime: Math.floor(stats?.mtimeMs ?? Date.now()),
size: stats?.size ?? 0,
type: "file",
},

View File

@@ -84,6 +84,27 @@ describe("CLIStorageEventManagerAdapter", () => {
expect(created.stat?.size).toBe(42);
});
it("floors sub-millisecond stat timestamps so mobile clients do not receive floats", async () => {
const basePath = "/vault/base";
const adapter = new CLIStorageEventManagerAdapter(basePath, undefined, true);
const handlers = makeHandlers();
await adapter.watch.beginWatch(handlers);
const addCallback = mockWatcher.on.mock.calls.find(([event]) => event === "add")![1] as (
filePath: string,
stats: any
) => void;
// Linux fs.Stats carry nanosecond-derived sub-millisecond precision.
const floatStats = { ctimeMs: 1778511180024.462, mtimeMs: 1778511180999.913, size: 7 };
addCallback(`${basePath}/note.md`, floatStats);
const created = (handlers.onCreate as ReturnType<typeof vi.fn>).mock.calls[0][0] as NodeFile;
expect(created.stat?.ctime).toBe(1778511180024);
expect(created.stat?.mtime).toBe(1778511180999);
});
it("close() calls watcher.close()", async () => {
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
const handlers = makeHandlers();

View File

@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.9-cli",
"version": "1.0.11-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.9-webapp",
"version": "1.0.11-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.9-webpeer",
"version": "1.0.11-webpeer",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -2,6 +2,7 @@ import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/commo
import type { IStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
import type { Stat, App } from "obsidian";
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
/**
* Storage adapter implementation for Obsidian
@@ -40,15 +41,15 @@ export class ObsidianStorageAdapter implements IStorageAdapter<Stat> {
}
async write(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
return await this.app.vault.adapter.write(path, data, options);
return await this.app.vault.adapter.write(path, data, toIntegerTimestamps(options));
}
async writeBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), options);
return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
}
async append(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
return await this.app.vault.adapter.append(path, data, options);
return await this.app.vault.adapter.append(path, data, toIntegerTimestamps(options));
}
list(basePath: string): Promise<{ files: string[]; folders: string[] }> {

View File

@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from "vitest";
import type { App } from "obsidian";
import { ObsidianStorageAdapter } from "./ObsidianStorageAdapter";
describe("ObsidianStorageAdapter", () => {
it("floors write-option timestamps before calling Obsidian storage methods", async () => {
const write = vi.fn().mockResolvedValue(undefined);
const writeBinary = vi.fn().mockResolvedValue(undefined);
const append = vi.fn().mockResolvedValue(undefined);
const app = {
vault: {
adapter: {
write,
writeBinary,
append,
},
},
} as unknown as App;
const adapter = new ObsidianStorageAdapter(app);
const options = { ctime: 1778511180024.462, mtime: 1778511180999.913 };
const expectedOptions = { ctime: 1778511180024, mtime: 1778511180999 };
await adapter.write("note.md", "text", options);
await adapter.writeBinary("image.bin", new ArrayBuffer(0), options);
await adapter.append("log.md", "text", options);
expect(write).toHaveBeenCalledWith("note.md", "text", expectedOptions);
expect(writeBinary).toHaveBeenCalledWith("image.bin", expect.any(ArrayBuffer), expectedOptions);
expect(append).toHaveBeenCalledWith("log.md", "text", expectedOptions);
expect(options).toEqual({ ctime: 1778511180024.462, mtime: 1778511180999.913 });
});
});

View File

@@ -2,6 +2,7 @@ import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/commo
import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
import type { TFile, App, TFolder } from "obsidian";
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
/**
* Vault adapter implementation for Obsidian
@@ -23,19 +24,19 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile, TFolder> {
}
async modify(file: TFile, data: string, options?: UXDataWriteOptions): Promise<void> {
return await this.app.vault.modify(file, data, options);
return await this.app.vault.modify(file, data, toIntegerTimestamps(options));
}
async modifyBinary(file: TFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
return await this.app.vault.modifyBinary(file, toArrayBuffer(data), options);
return await this.app.vault.modifyBinary(file, toArrayBuffer(data), toIntegerTimestamps(options));
}
async create(path: string, data: string, options?: UXDataWriteOptions): Promise<TFile> {
return await this.app.vault.create(path, data, options);
return await this.app.vault.create(path, data, toIntegerTimestamps(options));
}
async createBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<TFile> {
return await this.app.vault.createBinary(path, toArrayBuffer(data), options);
return await this.app.vault.createBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
}
async rename(file: TFile, newPath: string): Promise<void> {

View File

@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { App, TFile } from "obsidian";
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
describe("ObsidianVaultAdapter.read", () => {
describe("ObsidianVaultAdapter", () => {
it("preserves a UTF-8 BOM so the content size matches the file stat", async () => {
const path = "Transcripts/字幕.md";
const contentWithoutBom = "字幕の検証行です。\n";
@@ -34,4 +34,34 @@ describe("ObsidianVaultAdapter.read", () => {
expect(adapterRead).toHaveBeenCalledWith(path);
expect(read).not.toHaveBeenCalled();
});
it("floors write-option timestamps before calling Obsidian vault methods", async () => {
const modify = vi.fn().mockResolvedValue(undefined);
const modifyBinary = vi.fn().mockResolvedValue(undefined);
const create = vi.fn().mockResolvedValue({});
const createBinary = vi.fn().mockResolvedValue({});
const app = {
vault: {
modify,
modifyBinary,
create,
createBinary,
},
} as unknown as App;
const file = { path: "note.md" } as TFile;
const adapter = new ObsidianVaultAdapter(app);
const options = { ctime: 1778511180024.462, mtime: 1778511180999.913 };
const expectedOptions = { ctime: 1778511180024, mtime: 1778511180999 };
await adapter.modify(file, "text", options);
await adapter.modifyBinary(file, new ArrayBuffer(0), options);
await adapter.create("created.md", "text", options);
await adapter.createBinary("created.bin", new ArrayBuffer(0), options);
expect(modify).toHaveBeenCalledWith(file, "text", expectedOptions);
expect(modifyBinary).toHaveBeenCalledWith(file, expect.any(ArrayBuffer), expectedOptions);
expect(create).toHaveBeenCalledWith("created.md", "text", expectedOptions);
expect(createBinary).toHaveBeenCalledWith("created.bin", expect.any(ArrayBuffer), expectedOptions);
expect(options).toEqual({ ctime: 1778511180024.462, mtime: 1778511180999.913 });
});
});

View File

@@ -0,0 +1,25 @@
import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
/**
* Coerce the timestamp fields of a write-options object to integer milliseconds.
*
* On mobile, Obsidian forwards `mtime`/`ctime` to Capacitor's
* Filesystem.setTimes, whose native binding casts the value to a Java `Long`.
* A non-integer (float) timestamp makes that cast throw
* `ClassCastException: Double cannot be cast to Long`, which crashes the app on
* launch as soon as such a document is replicated in. Float timestamps can
* enter the database from any client that stores `fs.Stats.mtimeMs` without
* flooring. Flooring at the storage boundary guarantees every Obsidian write
* carries an integer, so a float timestamp already present in the mesh cannot
* brick the app.
*
* Returns a shallow copy so the caller's options object is not mutated; passes
* `undefined` through unchanged.
*/
export function toIntegerTimestamps(options?: UXDataWriteOptions): UXDataWriteOptions | undefined {
if (!options) return options;
const sanitized: UXDataWriteOptions = { ...options };
if (typeof sanitized.mtime === "number") sanitized.mtime = Math.floor(sanitized.mtime);
if (typeof sanitized.ctime === "number") sanitized.ctime = Math.floor(sanitized.ctime);
return sanitized;
}

View File

@@ -12,6 +12,34 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
## Unreleased
## 1.0.11
9th August, 2026
### Setup and compatibility
#### Fixed
- Fast Setup now uses Standard Fetch when CouchDB's 'Use Internal API' setting is enabled, avoiding a streaming request path which Obsidian's buffered API cannot support (#1020).
- Custom headers alone continue to use Fast Fetch when browser CORS permits them; Standard Fetch clears any obsolete Fast Fetch checkpoint after resetting the local database.
### Synchronisation and storage
#### Fixed
- Fractional file timestamps no longer cause affected mobile clients to crash after synchronisation (#1087, PR #1039). Thank you to @andrewleech for the contribution!
- Timestamps are now normalised in the command-line tool and before Obsidian's native file-system writes.
## 1.0.10
9th August, 2026
### Setup and compatibility
#### Fixed
- Fast Setup now sends configured CouchDB custom headers with every changes-feed request, allowing reverse proxies such as Cloudflare Access to authenticate initial setup in the same way as ordinary synchronisation ([Commonlib PR #82](https://github.com/vrtmrz/livesync-commonlib/pull/82)). Thank you to @nimula for the contribution!
## 1.0.9
8th August, 2026

View File

@@ -21,5 +21,7 @@
"1.0.6": "1.7.2",
"1.0.7": "1.7.2",
"1.0.8": "1.7.2",
"1.0.9": "1.7.2"
"1.0.9": "1.7.2",
"1.0.10": "1.7.2",
"1.0.11": "1.7.2"
}