From a3a09df3c8cf72797e17a2d3ac0da3e36cf33160 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Thu, 16 Jul 2026 14:41:30 +1000 Subject: [PATCH] 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; +}