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