Preserve CLI file creation times across in-place edits

This commit is contained in:
vorotamoroz
2026-10-15 11:07:43 +00:00
parent 1ce753a302
commit 0bcd9af6a5
9 changed files with 186 additions and 14 deletions
+4 -4
View File
@@ -23,7 +23,7 @@
"@smithy/types": "^4.14.3", "@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0", "@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/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
@@ -4389,9 +4389,9 @@
} }
}, },
"node_modules/@vrtmrz/livesync-commonlib": { "node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.39", "version": "0.1.40",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.39.tgz", "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.40.tgz",
"integrity": "sha512-AUjQI1fgR+4KOYpuwZfFjVJeSt9JDNSLMAlLoLuh/553CzMnqTfwqopVItKP3b3SrdVQbyGbJLQeGEhuDMoJ9Q==", "integrity": "sha512-GVskcVFoQIpb45rR+QT9Wjtdqc6oAxYz5IB2WeJwusWZ8lTmqVrba+6YD9FTtsbzqbPOyJ/LdQxnET7SUexVtw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.808.0", "@aws-sdk/client-s3": "^3.808.0",
+1 -1
View File
@@ -191,7 +191,7 @@
"@smithy/types": "^4.14.3", "@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0", "@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/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
+2 -2
View File
@@ -16,7 +16,7 @@ import type { CLICommandContext, CLIOptions } from "./types";
import { toArrayBuffer, toDatabaseRelativePath } from "./utils"; import { toArrayBuffer, toDatabaseRelativePath } from "./utils";
import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./p2p"; import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./p2p";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; 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 { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
import { import {
CENTRAL_COMPATIBILITY_REJECTION_REASONS, CENTRAL_COMPATIBILITY_REJECTION_REASONS,
@@ -232,7 +232,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
await core.serviceModules.storageAccess.writeFileAuto(destinationDatabasePath, toArrayBuffer(sourceData), { await core.serviceModules.storageAccess.writeFileAuto(destinationDatabasePath, toArrayBuffer(sourceData), {
mtime: Math.floor(sourceStat.mtimeMs), mtime: Math.floor(sourceStat.mtimeMs),
ctime: Math.floor(sourceStat.ctimeMs), ctime: getNodeFileCreationTime(sourceStat),
}); });
const destinationPathWithPrefix = destinationDatabasePath as FilePathWithPrefix; const destinationPathWithPrefix = destinationDatabasePath as FilePathWithPrefix;
const stored = await core.serviceModules.fileHandler.storeFileToDB(destinationPathWithPrefix, true); const stored = await core.serviceModules.fileHandler.storeFileToDB(destinationPathWithPrefix, true);
@@ -23,6 +23,8 @@ import {
replicationFailed, replicationFailed,
} from "@vrtmrz/livesync-commonlib/replication"; } from "@vrtmrz/livesync-commonlib/replication";
vi.mock("node:fs/promises", { spy: true });
function createStandardIoMock() { function createStandardIoMock() {
return { return {
readStdin: vi.fn(async () => ""), readStdin: vi.fn(async () => ""),
@@ -363,6 +365,38 @@ describe("runCommand abnormal cases", () => {
expect(core.serviceModules.databaseFileAccess.fetch).toHaveBeenCalledWith("file.md", "9-invalid", true); 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 () => { it("push rejects when source file does not exist", async () => {
const core = createCoreMock(); const core = createCoreMock();
+40 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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 { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import * as chokidar from "chokidar"; 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<CliCommandRunner>(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 () => { it("runs the mirror scan once and exits without starting file watching", async () => {
const enumerate = vi.spyOn(ServiceFileAccessCLI.prototype, "getFiles"); const enumerate = vi.spyOn(ServiceFileAccessCLI.prototype, "getFiles");
const watch = vi.mocked(chokidar.watch); const watch = vi.mocked(chokidar.watch);
@@ -13,7 +13,7 @@ import type { FileEventItemSentinel } from "@vrtmrz/livesync-commonlib/compat/ma
import type { NodeFile, NodeFolder } from "@/apps/cli/adapters/NodeTypes"; import type { NodeFile, NodeFolder } from "@/apps/cli/adapters/NodeTypes";
import { watch as chokidarWatch, type FSWatcher } from "chokidar"; import { watch as chokidarWatch, type FSWatcher } from "chokidar";
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules"; 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"; import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
/** /**
@@ -123,7 +123,7 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
stat: { stat: {
// Floor to integer milliseconds; Linux fs.Stats.*Ms carry sub-millisecond // Floor to integer milliseconds; Linux fs.Stats.*Ms carry sub-millisecond
// precision, and timestamps are stored as integer ms everywhere else. // 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()), mtime: Math.floor(stats?.mtimeMs ?? Date.now()),
size: stats?.size ?? 0, size: stats?.size ?? 0,
type: "file", type: "file",
@@ -105,6 +105,59 @@ describe("CLIStorageEventManagerAdapter", () => {
expect(created.stat?.mtime).toBe(1778511180999); 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 () => { it("close() calls watcher.close()", async () => {
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true); const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
const handlers = makeHandlers(); const handlers = makeHandlers();
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process"; 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 { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { import {
@@ -21,6 +21,7 @@ import {
} from "../runner/liveSyncWorkflow.ts"; } from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.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_CLI_TIMEOUT_MS ??= "30000";
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000"; process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000";
@@ -37,6 +38,7 @@ const noteContent = [
"", "",
].join("\n"); ].join("\n");
const e2eePassphrase = "real-obsidian-cli-compatibility-e2e"; const e2eePassphrase = "real-obsidian-cli-compatibility-e2e";
const editedNoteContent = `${noteContent}\nCLI in-place edit.\n`;
type LiveSyncCliCommand = { type LiveSyncCliCommand = {
executable: string; executable: string;
@@ -51,6 +53,7 @@ type CliResult = {
type CliFileInfo = { type CliFileInfo = {
id: string; id: string;
children: string[]; children: string[];
expectedCtime?: number;
}; };
function parseCommandLine(value: string): string[] { function parseCommandLine(value: string): string[] {
@@ -231,6 +234,24 @@ async function writeCliNote(
if (!fileInfo.id || !Array.isArray(fileInfo.children) || fileInfo.children.length === 0) { 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}`); 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"]); await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "sync"]);
return fileInfo; return fileInfo;
} }
@@ -246,7 +267,7 @@ async function waitForVaultContent(
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { try {
lastContent = await readFile(fullPath, "utf-8"); lastContent = await readFile(fullPath, "utf-8");
if (lastContent === noteContent) { if (lastContent === editedNoteContent) {
return lastContent; return lastContent;
} }
} catch (error) { } catch (error) {
@@ -335,8 +356,31 @@ async function main(): Promise<void> {
await pushLocalChanges(obsidianCli.binary, session.cliEnv); await pushLocalChanges(obsidianCli.binary, session.cliEnv);
const received = await waitForVaultContent(vault.path, notePath); const received = await waitForVaultContent(vault.path, notePath);
assertEqual(received, noteContent, "The real Obsidian plug-in did not materialise the CLI-created note."); assertEqual(received, editedNoteContent, "The real Obsidian plug-in did not materialise the CLI-edited note.");
console.log("CLI-created encrypted note was retrieved by the real Obsidian plug-in with identical content."); if (cliFileInfo.expectedCtime !== undefined) {
const receivedCtime = await evalObsidianJson<number>(
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 { } finally {
if (session) { if (session) {
await session.app.stop(); await session.app.stop();
+2
View File
@@ -16,6 +16,8 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
#### Fixed #### 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) - `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. - 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. - 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.