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
This commit is contained in:
Andrew Leech
2026-07-22 14:08:21 +10:00
parent f54d162ef9
commit 4393a49cba
2 changed files with 25 additions and 2 deletions
@@ -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",
},
@@ -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();