Persist displayed branches through conflict operations

This commit is contained in:
vorotamoroz
2026-07-22 16:01:57 +00:00
parent 59aa8ad9a9
commit f24c543dd3
14 changed files with 724 additions and 43 deletions
@@ -10,6 +10,7 @@ import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
import { StorageEventManagerCLI } from "@/apps/cli/managers/StorageEventManagerCLI";
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import type { IgnoreRules } from "./IgnoreRules";
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
/**
* Initialize service modules for CLI version
@@ -93,6 +94,7 @@ export function initialiseServiceModulesCLI(
path: services.path,
replication: services.replication,
storageAccess: storageAccess,
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
});
// Rebuilder (platform-independent)
+11 -1
View File
@@ -259,6 +259,14 @@ export class NodeKeyValueDBService<T extends ServiceContext = ServiceContext>
}
openSimpleStore<T>(kind: string): SimpleStore<T> {
// Service modules are composed before onSettingLoaded opens the file-
// backed database, so handle creation must not touch it. Actual store
// operations are deliberately fail-fast: the sequential lifecycle opens
// the database before scans, watchers, or replication start. Waiting here
// could hang forever after failed initialisation, or deadlock if a future
// initialisation handler tried to use the store it was waiting to open.
// Reset is likewise a transient unavailable boundary, not a wait state;
// callers must avoid store work there because an operation may fail.
const getDB = () => {
if (!this._kvDB) {
throw new Error("KeyValueDB is not initialized yet");
@@ -293,7 +301,9 @@ export class NodeKeyValueDBService<T extends ServiceContext = ServiceContext>
.filter((key) => key >= lower && key <= upper)
.map((key) => key.substring(prefix.length));
},
db: Promise.resolve(getDB()),
get db() {
return Promise.resolve(getDB());
},
} satisfies SimpleStore<T>;
}
}
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import type { NodeKeyValueDBDependencies } from "./NodeKeyValueDBService";
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
describe("NodeKeyValueDBService.openSimpleStore", () => {
it("creates a namespaced store handle before the backing database is initialised", () => {
const dependencies = {
appLifecycle: { onSettingLoaded: { addHandler: vi.fn() } },
databaseEvents: {
onResetDatabase: { addHandler: vi.fn() },
onDatabaseInitialisation: { addHandler: vi.fn() },
onUnloadDatabase: { addHandler: vi.fn() },
onCloseDatabase: { addHandler: vi.fn() },
},
vault: {},
} as unknown as NodeKeyValueDBDependencies;
const service = new NodeKeyValueDBService(
createServiceContext(),
dependencies,
"/tmp/obsidian-livesync-node-kv-handle-test.json"
);
expect(() => service.openSimpleStore("early-composition")).not.toThrow();
});
it("fails store operations promptly instead of waiting for lifecycle initialisation", async () => {
const dependencies = {
appLifecycle: { onSettingLoaded: { addHandler: vi.fn() } },
databaseEvents: {
onResetDatabase: { addHandler: vi.fn() },
onDatabaseInitialisation: { addHandler: vi.fn() },
onUnloadDatabase: { addHandler: vi.fn() },
onCloseDatabase: { addHandler: vi.fn() },
},
vault: {},
} as unknown as NodeKeyValueDBDependencies;
const service = new NodeKeyValueDBService(
createServiceContext(),
dependencies,
"/tmp/obsidian-livesync-node-kv-uninitialised-test.json"
);
const store = service.openSimpleStore("early-composition");
await expect(store.get("key")).rejects.toThrow("KeyValueDB is not initialized yet");
});
});
@@ -10,6 +10,7 @@ import { ServiceDatabaseFileAccessFSAPI } from "./DatabaseFileAccess";
import { StorageEventManagerFSAPI } from "@/apps/webapp/managers/StorageEventManagerFSAPI";
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
export interface FSAPIServiceModules extends ServiceModules {
vaultAccess: FileAccessFSAPI;
@@ -84,6 +85,7 @@ export function initialiseServiceModulesFSAPI(
path: services.path,
replication: services.replication,
storageAccess: storageAccess,
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
});
// Rebuilder (platform-independent)
+2
View File
@@ -46,6 +46,7 @@ import { useReviewHarness } from "./serviceFeatures/useReviewHarness.ts";
import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync/P2PReplicator/P2PReplicationUI.ts";
import { useCompatibilityReview } from "./serviceFeatures/compatibilityReview.ts";
import { createObsidianCompatibilityReviewUi } from "./serviceFeatures/compatibilityReviewObsidian.ts";
import { createFileReflectionProvenance } from "./serviceModules/FileReflectionProvenance.ts";
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
export default class ObsidianLiveSyncPlugin extends Plugin {
core: LiveSyncCore;
@@ -104,6 +105,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
path: services.path,
replication: services.replication,
storageAccess: storageAccess,
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
});
const rebuilder = new ServiceRebuilder({
events: services.context.events,
@@ -0,0 +1,27 @@
import {
StoredFileReflectionProvenance,
type FileReflectionProvenanceRecord,
} from "@vrtmrz/livesync-commonlib/compat/interfaces/FileReflectionProvenance";
import type { SimpleStore } from "@vrtmrz/livesync-commonlib/compat/common/utils";
export const FILE_REFLECTION_PROVENANCE_STORE = "file-reflection-provenance-v1";
export type FileReflectionProvenanceStoreFactory = {
openSimpleStore<T>(kind: string): SimpleStore<T>;
};
/**
* Create the device-local record which links a Vault file to the exact
* database revision most recently reflected in that Vault.
*
* This runs during service composition, before KeyValueDB is opened. The
* returned namespaced handle is inert until its first operation; normal hosts
* complete the sequential onSettingLoaded lifecycle before Vault scanning,
* watching, or replication can invoke it. Operations are never held waiting for
* readiness; they fail on a lifecycle violation and may fail during reset.
*/
export function createFileReflectionProvenance(keyValueDB: FileReflectionProvenanceStoreFactory) {
return new StoredFileReflectionProvenance(
keyValueDB.openSimpleStore<FileReflectionProvenanceRecord>(FILE_REFLECTION_PROVENANCE_STORE)
);
}
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import type { SimpleStore } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { FileReflectionProvenanceRecord } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileReflectionProvenance";
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
createFileReflectionProvenance,
FILE_REFLECTION_PROVENANCE_STORE,
} from "./FileReflectionProvenance";
describe("createFileReflectionProvenance", () => {
it("uses one reset-scoped host store for exact reflected revisions", async () => {
const values = new Map<string, FileReflectionProvenanceRecord>();
const store = {
get: vi.fn(async (key: string) => values.get(key)),
set: vi.fn(async (key: string, value: FileReflectionProvenanceRecord) => {
values.set(key, value);
}),
delete: vi.fn(async (key: string) => {
values.delete(key);
}),
keys: vi.fn(async () => [...values.keys()]),
db: undefined,
} as unknown as SimpleStore<FileReflectionProvenanceRecord>;
const openSimpleStore = vi.fn().mockReturnValue(store);
const path = "note.md" as FilePathWithPrefix;
const provenance = createFileReflectionProvenance({ openSimpleStore });
expect(openSimpleStore).toHaveBeenCalledWith(FILE_REFLECTION_PROVENANCE_STORE);
await provenance.set(path, { revision: "3-displayed", observedStorageMtime: 123.456 });
expect(openSimpleStore).toHaveBeenCalledTimes(1);
await expect(provenance.get(path)).resolves.toEqual({
revision: "3-displayed",
observedStorageMtime: 123.456,
});
});
});