From 4393a49cba6777db4a6f0818901b3f320d904f5f Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Thu, 16 Jul 2026 14:41:20 +1000 Subject: [PATCH 1/4] 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 --- .../managers/CLIStorageEventManagerAdapter.ts | 6 ++++-- ...CLIStorageEventManagerAdapter.unit.spec.ts | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts b/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts index fd3488b9..cfc903a3 100644 --- a/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts +++ b/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts @@ -116,8 +116,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", }, diff --git a/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts b/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts index cd3e4156..602bc217 100644 --- a/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts +++ b/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts @@ -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).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(); From a3a09df3c8cf72797e17a2d3ac0da3e36cf33160 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Thu, 16 Jul 2026 14:41:30 +1000 Subject: [PATCH 2/4] 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 --- .../ObsidianStorageAdapter.ts | 7 +++--- .../ObsidianVaultAdapter.ts | 9 ++++--- .../sanitizeWriteOptions.ts | 25 +++++++++++++++++++ 3 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 src/serviceModules/FileSystemAdapters/sanitizeWriteOptions.ts diff --git a/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts index a9133018..508b64a9 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts @@ -2,6 +2,7 @@ import type { UXDataWriteOptions } from "@lib/common/types"; import type { IStorageAdapter } from "@lib/serviceModules/adapters"; import { toArrayBuffer } from "@lib/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 { } async write(path: string, data: string, options?: UXDataWriteOptions): Promise { - 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 { - 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 { - 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[] }> { diff --git a/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts index 42ab566c..6a5c021b 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts @@ -2,6 +2,7 @@ import type { UXDataWriteOptions } from "@lib/common/types"; import type { IVaultAdapter } from "@lib/serviceModules/adapters"; import { toArrayBuffer } from "@lib/serviceModules/FileAccessBase"; import type { TFile, App, TFolder } from "obsidian"; +import { toIntegerTimestamps } from "./sanitizeWriteOptions"; /** * Vault adapter implementation for Obsidian @@ -22,19 +23,19 @@ export class ObsidianVaultAdapter implements IVaultAdapter { } async modify(file: TFile, data: string, options?: UXDataWriteOptions): Promise { - 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 { - 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 { - 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 { - return await this.app.vault.createBinary(path, toArrayBuffer(data), options); + return await this.app.vault.createBinary(path, toArrayBuffer(data), toIntegerTimestamps(options)); } async delete(file: TFile | TFolder, force = false): Promise { diff --git a/src/serviceModules/FileSystemAdapters/sanitizeWriteOptions.ts b/src/serviceModules/FileSystemAdapters/sanitizeWriteOptions.ts new file mode 100644 index 00000000..4cb92355 --- /dev/null +++ b/src/serviceModules/FileSystemAdapters/sanitizeWriteOptions.ts @@ -0,0 +1,25 @@ +import type { UXDataWriteOptions } from "@lib/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; +} From 21d904cfd688d2c718a0bab7c66e34dd9e270e11 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 9 Aug 2026 08:24:37 +0000 Subject: [PATCH 3/4] 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. --- .../ObsidianStorageAdapter.unit.spec.ts | 32 +++++++++++++++++++ .../ObsidianVaultAdapter.unit.spec.ts | 32 ++++++++++++++++++- .../sanitizeWriteOptions.ts | 2 +- 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.unit.spec.ts diff --git a/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.unit.spec.ts b/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.unit.spec.ts new file mode 100644 index 00000000..7172879f --- /dev/null +++ b/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.unit.spec.ts @@ -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 }); + }); +}); diff --git a/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.unit.spec.ts b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.unit.spec.ts index 5a307d52..0c22f212 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.unit.spec.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.unit.spec.ts @@ -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 }); + }); }); diff --git a/src/serviceModules/FileSystemAdapters/sanitizeWriteOptions.ts b/src/serviceModules/FileSystemAdapters/sanitizeWriteOptions.ts index 4cb92355..3c055c16 100644 --- a/src/serviceModules/FileSystemAdapters/sanitizeWriteOptions.ts +++ b/src/serviceModules/FileSystemAdapters/sanitizeWriteOptions.ts @@ -1,4 +1,4 @@ -import type { UXDataWriteOptions } from "@lib/common/types"; +import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types"; /** * Coerce the timestamp fields of a write-options object to integer milliseconds. From 08b55b76771435a9671c0879307733827eff299e Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 9 Aug 2026 09:12:04 +0000 Subject: [PATCH 4/4] docs: note fractional timestamp fix --- updates.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/updates.md b/updates.md index c25953a3..67f0c0c8 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,13 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ## Unreleased +### 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