diff --git a/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts b/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts index 1b59a6f6..37573650 100644 --- a/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts +++ b/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts @@ -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", }, diff --git a/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts b/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts index 6d9c333d..b01fb554 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(); diff --git a/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts index 2cec3048..0109e282 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts @@ -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 { } 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/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.ts b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts index 291fd290..96486a78 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts @@ -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 { } 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 rename(file: TFile, newPath: string): Promise { 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 new file mode 100644 index 00000000..3c055c16 --- /dev/null +++ b/src/serviceModules/FileSystemAdapters/sanitizeWriteOptions.ts @@ -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; +} 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