diff --git a/package-lock.json b/package-lock.json index 3b44d057..cc64cbc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.1.39", + "@vrtmrz/livesync-commonlib": "0.1.40", "@vrtmrz/obsidian-plugin-kit": "0.1.4", "@vrtmrz/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", @@ -4389,9 +4389,9 @@ } }, "node_modules/@vrtmrz/livesync-commonlib": { - "version": "0.1.39", - "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.39.tgz", - "integrity": "sha512-AUjQI1fgR+4KOYpuwZfFjVJeSt9JDNSLMAlLoLuh/553CzMnqTfwqopVItKP3b3SrdVQbyGbJLQeGEhuDMoJ9Q==", + "version": "0.1.40", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.40.tgz", + "integrity": "sha512-GVskcVFoQIpb45rR+QT9Wjtdqc6oAxYz5IB2WeJwusWZ8lTmqVrba+6YD9FTtsbzqbPOyJ/LdQxnET7SUexVtw==", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.808.0", diff --git a/package.json b/package.json index d8b01112..c725c1f4 100644 --- a/package.json +++ b/package.json @@ -191,7 +191,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.1.39", + "@vrtmrz/livesync-commonlib": "0.1.40", "@vrtmrz/obsidian-plugin-kit": "0.1.4", "@vrtmrz/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", diff --git a/src/apps/cli/commands/runCommand.ts b/src/apps/cli/commands/runCommand.ts index 26e22f9b..32887a17 100644 --- a/src/apps/cli/commands/runCommand.ts +++ b/src/apps/cli/commands/runCommand.ts @@ -16,7 +16,7 @@ import type { CLICommandContext, CLIOptions } from "./types"; import { toArrayBuffer, toDatabaseRelativePath } from "./utils"; import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./p2p"; import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; -import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node"; +import { fsPromises as fs, getNodeFileCreationTime, path } from "@vrtmrz/livesync-commonlib/node"; import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput"; import { CENTRAL_COMPATIBILITY_REJECTION_REASONS, @@ -232,7 +232,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext await core.serviceModules.storageAccess.writeFileAuto(destinationDatabasePath, toArrayBuffer(sourceData), { mtime: Math.floor(sourceStat.mtimeMs), - ctime: Math.floor(sourceStat.ctimeMs), + ctime: getNodeFileCreationTime(sourceStat), }); const destinationPathWithPrefix = destinationDatabasePath as FilePathWithPrefix; const stored = await core.serviceModules.fileHandler.storeFileToDB(destinationPathWithPrefix, true); diff --git a/src/apps/cli/commands/runCommand.unit.spec.ts b/src/apps/cli/commands/runCommand.unit.spec.ts index 61d253a1..21a1de6a 100644 --- a/src/apps/cli/commands/runCommand.unit.spec.ts +++ b/src/apps/cli/commands/runCommand.unit.spec.ts @@ -23,6 +23,8 @@ import { replicationFailed, } from "@vrtmrz/livesync-commonlib/replication"; +vi.mock("node:fs/promises", { spy: true }); + function createStandardIoMock() { return { readStdin: vi.fn(async () => ""), @@ -363,6 +365,38 @@ describe("runCommand abnormal cases", () => { expect(core.serviceModules.databaseFileAccess.fetch).toHaveBeenCalledWith("file.md", "9-invalid", true); }); + it.each([ + { birthtimeMs: 1000.9, expectedCtime: 1000 }, + { birthtimeMs: 0, expectedCtime: 2000 }, + ])( + "push selects the source creation time with a change-time fallback (%j)", + async ({ birthtimeMs, expectedCtime }) => { + const core = createCoreMock(); + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-push-time-")); + const source = path.join(directory, "source.md"); + await fs.writeFile(source, "content"); + const sourceStat = await fs.stat(source); + const stat = vi + .spyOn(fs, "stat") + .mockResolvedValue({ ...sourceStat, birthtimeMs, ctimeMs: 2000.8, mtimeMs: 3000.7 }); + try { + const context = { core, vaultPath: "/tmp/vault", databasePath: "/tmp/vault" } as any; + expect(await runCommand(makeOptions("push", [source, "note.md"]), context)).toBe(true); + expect(core.serviceModules.storageAccess.writeFileAuto).toHaveBeenCalledWith( + "note.md", + expect.any(ArrayBuffer), + { + ctime: expectedCtime, + mtime: 3000, + } + ); + } finally { + stat.mockRestore(); + await fs.rm(directory, { recursive: true, force: true }); + } + } + ); + it("push rejects when source file does not exist", async () => { const core = createCoreMock(); diff --git a/src/apps/cli/main.bootstrap.unit.spec.ts b/src/apps/cli/main.bootstrap.unit.spec.ts index a63fcbd1..9e262838 100644 --- a/src/apps/cli/main.bootstrap.unit.spec.ts +++ b/src/apps/cli/main.bootstrap.unit.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { appendFile, mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import * as chokidar from "chokidar"; @@ -204,6 +204,45 @@ describe("CLI database preparation", () => { } ); + it("retains filesystem creation time in database Metadata after an in-place edit", async (context) => { + const note = join(vaultPath, "notes/local.md"); + const initial = await stat(note); + await new Promise((resolve) => setTimeout(resolve, 20)); + await appendFile(note, "\nedit"); + const edited = await stat(note); + if ( + initial.birthtimeMs <= 0 || + edited.birthtimeMs !== initial.birthtimeMs || + Math.floor(edited.ctimeMs) <= Math.floor(initial.birthtimeMs) + ) { + context.skip(); + } + + const observed: { before?: number; after?: number; content?: string; stored?: boolean } = {}; + const runner = vi.fn(async (_options, { core }) => { + const filePath = "notes/local.md" as FilePathWithPrefix; + const before = await core.serviceModules.databaseFileAccess.fetch(filePath); + if (before) observed.before = before.stat.ctime; + await appendFile(note, "\nsecond edit"); + observed.stored = await core.serviceModules.fileHandler.storeFileToDB(filePath, true); + const after = await core.serviceModules.databaseFileAccess.fetch(filePath); + if (after) { + observed.after = after.stat.ctime; + observed.content = await after.body.text(); + } + return false; + }); + + await start("daemon", runner); + expect(runner).toHaveBeenCalledOnce(); + expect(observed).toEqual({ + before: Math.floor(initial.birthtimeMs), + after: Math.floor(initial.birthtimeMs), + content: "local content\nedit\nsecond edit", + stored: true, + }); + }); + it("runs the mirror scan once and exits without starting file watching", async () => { const enumerate = vi.spyOn(ServiceFileAccessCLI.prototype, "getFiles"); const watch = vi.mocked(chokidar.watch); diff --git a/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts b/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts index 37573650..e6369fce 100644 --- a/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts +++ b/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts @@ -13,7 +13,7 @@ import type { FileEventItemSentinel } from "@vrtmrz/livesync-commonlib/compat/ma import type { NodeFile, NodeFolder } from "@/apps/cli/adapters/NodeTypes"; import { watch as chokidarWatch, type FSWatcher } from "chokidar"; import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules"; -import { fsPromises as fs, path, type Stats } from "@vrtmrz/livesync-commonlib/node"; +import { fsPromises as fs, getNodeFileCreationTime, path, type Stats } from "@vrtmrz/livesync-commonlib/node"; import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput"; /** @@ -123,7 +123,7 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter { stat: { // 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()), + ctime: stats ? getNodeFileCreationTime(stats) : 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 b01fb554..bf8f6712 100644 --- a/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts +++ b/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts @@ -105,6 +105,59 @@ describe("CLIStorageEventManagerAdapter", () => { expect(created.stat?.mtime).toBe(1778511180999); }); + it("keeps creation time across add and change events when inode change time advances", async () => { + const adapter = new CLIStorageEventManagerAdapter("/vault", undefined, true); + const handlers = makeHandlers(); + await adapter.watch.beginWatch(handlers); + const add = mockWatcher.on.mock.calls.find(([event]) => event === "add")![1]; + const change = mockWatcher.on.mock.calls.find(([event]) => event === "change")![1]; + + add("/vault/note.md", { birthtimeMs: 1000.9, ctimeMs: 2000.8, mtimeMs: 2000.7, size: 7 }); + change("/vault/note.md", { birthtimeMs: 1000.9, ctimeMs: 3000.8, mtimeMs: 3000.7, size: 8 }); + + expect(handlers.onCreate).toHaveBeenCalledWith( + expect.objectContaining({ + stat: { ctime: 1000, mtime: 2000, size: 7, type: "file" }, + }) + ); + expect(handlers.onChange).toHaveBeenCalledWith( + expect.objectContaining({ + stat: { ctime: 1000, mtime: 3000, size: 8, type: "file" }, + }) + ); + }); + + it("falls back to change time when the filesystem supplies zero birth time", async () => { + const adapter = new CLIStorageEventManagerAdapter("/vault", undefined, true); + const handlers = makeHandlers(); + await adapter.watch.beginWatch(handlers); + const change = mockWatcher.on.mock.calls.find(([event]) => event === "change")![1]; + change("/vault/note.md", { birthtimeMs: 0, ctimeMs: 2000.8, mtimeMs: 3000.7, size: 8 }); + expect(handlers.onChange).toHaveBeenCalledWith( + expect.objectContaining({ + stat: { ctime: 2000, mtime: 3000, size: 8, type: "file" }, + }) + ); + }); + + it("uses the current time for a deletion without stats", async () => { + const now = vi.spyOn(Date, "now").mockReturnValue(4000); + try { + const adapter = new CLIStorageEventManagerAdapter("/vault", undefined, true); + const handlers = makeHandlers(); + await adapter.watch.beginWatch(handlers); + const unlink = mockWatcher.on.mock.calls.find(([event]) => event === "unlink")![1]; + unlink("/vault/note.md"); + expect(handlers.onDelete).toHaveBeenCalledWith( + expect.objectContaining({ + stat: { ctime: 4000, mtime: 4000, size: 0, type: "file" }, + }) + ); + } finally { + now.mockRestore(); + } + }); + it("close() calls watcher.close()", async () => { const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true); const handlers = makeHandlers(); diff --git a/test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts b/test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts index eeb73312..6daebc30 100644 --- a/test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts +++ b/test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { access, appendFile, mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { @@ -21,6 +21,7 @@ import { } from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { createTemporaryVault } from "../runner/vault.ts"; +import { evalObsidianJson } from "../runner/cli.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000"; @@ -37,6 +38,7 @@ const noteContent = [ "", ].join("\n"); const e2eePassphrase = "real-obsidian-cli-compatibility-e2e"; +const editedNoteContent = `${noteContent}\nCLI in-place edit.\n`; type LiveSyncCliCommand = { executable: string; @@ -51,6 +53,7 @@ type CliResult = { type CliFileInfo = { id: string; children: string[]; + expectedCtime?: number; }; function parseCommandLine(value: string): string[] { @@ -231,6 +234,24 @@ async function writeCliNote( if (!fileInfo.id || !Array.isArray(fileInfo.children) || fileInfo.children.length === 0) { throw new Error(`LiveSync CLI did not create complete metadata for ${notePath}: ${info.stdout}`); } + const storagePath = join(databasePath, notePath); + const originalStat = await stat(storagePath); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 20)); + await appendFile(storagePath, "\nCLI in-place edit.\n"); + // The mirror scanner compares modification times at a two-second resolution. + await utimes(storagePath, new Date(), new Date(originalStat.mtimeMs + 5000)); + const editedStat = await stat(storagePath); + if ( + originalStat.birthtimeMs > 0 && + originalStat.birthtimeMs === editedStat.birthtimeMs && + Math.floor(editedStat.ctimeMs) > Math.floor(originalStat.birthtimeMs) + ) { + fileInfo.expectedCtime = Math.floor(originalStat.birthtimeMs); + } + await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "mirror"]); + const editedInfo = await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "info", notePath]); + const editedFileInfo = JSON.parse(editedInfo.stdout) as CliFileInfo; + fileInfo.children = editedFileInfo.children; await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "sync"]); return fileInfo; } @@ -246,7 +267,7 @@ async function waitForVaultContent( while (Date.now() < deadline) { try { lastContent = await readFile(fullPath, "utf-8"); - if (lastContent === noteContent) { + if (lastContent === editedNoteContent) { return lastContent; } } catch (error) { @@ -335,8 +356,31 @@ async function main(): Promise { await pushLocalChanges(obsidianCli.binary, session.cliEnv); const received = await waitForVaultContent(vault.path, notePath); - assertEqual(received, noteContent, "The real Obsidian plug-in did not materialise the CLI-created note."); - console.log("CLI-created encrypted note was retrieved by the real Obsidian plug-in with identical content."); + assertEqual(received, editedNoteContent, "The real Obsidian plug-in did not materialise the CLI-edited note."); + if (cliFileInfo.expectedCtime !== undefined) { + const receivedCtime = await evalObsidianJson( + obsidianCli.binary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + `const entry=await core.localDatabase.getDBEntry(${JSON.stringify(notePath)},undefined,false,true);`, + "return JSON.stringify(entry.ctime);", + "})()", + ].join(""), + session.cliEnv + ); + assertEqual( + receivedCtime, + cliFileInfo.expectedCtime, + "The CLI edit changed creation time in synchronised Metadata." + ); + console.log("The CLI edit retained the filesystem creation time in the real Obsidian client's Metadata."); + } else { + console.log("Creation-time verification skipped: native stats did not expose distinct creation and change times."); + } + console.log( + "CLI-edited encrypted note was retrieved by the real Obsidian plug-in with identical content." + ); } finally { if (session) { await session.app.stop(); diff --git a/updates.md b/updates.md index 6ecc1ced..d362d1a4 100644 --- a/updates.md +++ b/updates.md @@ -16,6 +16,8 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi #### Fixed +- We can edit files through the CLI without resetting their creation time where the filesystem supplies it. (#1243) + - Filesystems without birth-time support retain the existing change-time fallback. File replacement and restoration of a synchronised creation time remain subject to filesystem limitations. - `p2p-host` restores configured P2P automatic synchronisation, following, and change announcements after initialisation. (#1247) - Headless peer acceptance follows the configured policy. Single-operation commands retain their automatic synchronisation suspension. - We can select CLI-only Docker patches by their full release tag, such as `1.0.36-patch1-cli`, while keeping the Obsidian plug-in version.