Adopt Commonlib 0.2.0 with injected device storage

This commit is contained in:
vorotamoroz committed 2026-10-23 13:56:54 +00:00
1 parent e56098eb57
commit 96ad2737a7
60 files changed
+1499 -541

No files matched your search

+2 -2
View File
@@ -719,8 +719,8 @@ src/apps/cli/
│ └── ServiceFileAccessImpl.ts
├── services/
│ ├── NodeKeyValueDBService.ts
│ ├── NodeServiceHub.ts
│ └── NodeSettingService.ts
│ ├── NodeLocalStorage.ts
│ └── NodeServiceHub.ts
├── test/
│ ├── test-e2e-two-vaults-common.sh
│ ├── test-e2e-two-vaults-matrix.sh
+21
View File
@@ -88,6 +88,27 @@ describe("CLI database preparation", () => {
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
}
it("loads legacy device values and persists configuration through the service-owned store", async () => {
const localStoragePath = join(directory, ".livesync", "runtime", "local-storage.json");
await mkdir(join(directory, ".livesync", "runtime"), { recursive: true });
await writeFile(localStoragePath, JSON.stringify({ ["__proto__"]: "legacy value" }));
let legacyValue: string | null | undefined;
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
legacyValue = await core.services.setting.deviceValues.getValue("__proto__");
expect(core.services.config).toBe(core.services.setting.config);
await core.services.config.setValue("bootstrap-probe", "config value");
return false;
});
await start("ls", runner);
expect(legacyValue).toBe("legacy value");
const stored = JSON.parse(await readFile(localStoragePath, "utf-8")) as Record<string, string>;
expect(Object.prototype.hasOwnProperty.call(stored, "__proto__")).toBe(true);
expect(stored["__proto__"]).toBe("legacy value");
expect(Object.values(stored)).toContain("config value");
});
it.each([REMOTE_P2P, REMOTE_COUCHDB])(
"restores configured P2P automation before the host connects (%s)",
async (remoteType) => {
-4
View File
@@ -1,6 +1,5 @@
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore";
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
import {
@@ -78,7 +77,6 @@ const VAULT_SYNC_MODES: Readonly<Partial<Record<CLICommand, CLIVaultSyncMode>>>
},
};
ensureGlobalNodeLocalStorage();
defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
/** Injectable command boundary used by CLI integration probes. */
@@ -400,8 +398,6 @@ export async function main(
const settingsPath = options.settingsPath
? path.resolve(options.settingsPath)
: path.join(databasePath, SETTINGS_FILE);
configureNodeLocalStorage(path.join(databasePath, ".livesync", "runtime", "local-storage.json"));
// Resolve vault path: mirror positional argument takes priority,
// then --vault flag, otherwise fall back to databasePath.
const vaultPath =
+1 -1
View File
@@ -12,7 +12,7 @@
"buildRun": "npm run build && npm run cli --",
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
"check": "tsc -p tsconfig.json",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
+65 -94
View File
@@ -1,115 +1,86 @@
import { createDeletableValueStore, sanitiseValueStoreError, ValueStoreError } from "@vrtmrz/value-store";
import type { ValueDeleter, ValueStore } from "@vrtmrz/value-store";
import { fs as nodeFs, path as nodePath } from "@vrtmrz/livesync-commonlib/node";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
type LocalStorageShape = {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
clear(): void;
};
type NodeLocalStorage = ValueStore & ValueDeleter;
class PersistentNodeLocalStorage {
private storagePath: string | undefined;
private localStore: Record<string, string> = {};
class JsonFileValueBackend {
private values: Map<string, string>;
configure(storagePath: string) {
if (this.storagePath === storagePath) {
return;
}
this.storagePath = storagePath;
this.loadFromFile();
constructor(private readonly storagePath: string) {
this.values = this.loadValues();
}
private loadFromFile() {
if (!this.storagePath) {
this.localStore = {};
get(key: string): string | null {
return this.values.get(key) ?? null;
}
set(key: string, value: string): void {
const nextValues = new Map(this.values);
nextValues.set(key, value);
this.writeValues(nextValues);
this.values = nextValues;
}
delete(key: string): void {
if (!this.values.has(key)) {
return;
}
const nextValues = new Map(this.values);
nextValues.delete(key);
this.writeValues(nextValues);
this.values = nextValues;
}
private loadValues(): Map<string, string> {
let contents: string;
try {
const loaded = JSON.parse(nodeFs.readFileSync(this.storagePath, "utf-8")) as Record<string, string>;
this.localStore = { ...loaded };
contents = nodeFs.readFileSync(this.storagePath, "utf-8");
} catch (error) {
if (isMissingFileError(error)) {
return new Map();
}
throw sanitiseValueStoreError(error);
}
let document: unknown;
try {
document = JSON.parse(contents);
} catch {
this.localStore = {};
throw new ValueStoreError("invalid-data");
}
if (document === null || typeof document !== "object" || Array.isArray(document)) {
throw new ValueStoreError("invalid-data");
}
const values = new Map<string, string>();
for (const [key, value] of Object.entries(document)) {
if (typeof value !== "string") {
throw new ValueStoreError("invalid-data");
}
values.set(key, value);
}
return values;
}
private flushToFile() {
if (!this.storagePath) {
return;
}
private writeValues(values: ReadonlyMap<string, string>): void {
nodeFs.mkdirSync(nodePath.dirname(this.storagePath), { recursive: true });
nodeFs.writeFileSync(this.storagePath, JSON.stringify(this.localStore, null, 2), "utf-8");
}
getItem(key: string): string | null {
return this.localStore[key] ?? null;
}
setItem(key: string, value: string) {
this.localStore[key] = value;
this.flushToFile();
}
removeItem(key: string) {
if (!(key in this.localStore)) {
return;
}
delete this.localStore[key];
this.flushToFile();
}
clear() {
this.localStore = {};
this.flushToFile();
nodeFs.writeFileSync(this.storagePath, JSON.stringify(Object.fromEntries(values), null, 2), "utf-8");
}
}
const persistentNodeLocalStorage = new PersistentNodeLocalStorage();
function createNodeLocalStorageShim(): LocalStorageShape {
return {
getItem(key: string) {
return persistentNodeLocalStorage.getItem(key);
},
setItem(key: string, value: string) {
persistentNodeLocalStorage.setItem(key, value);
},
removeItem(key: string) {
persistentNodeLocalStorage.removeItem(key);
},
clear() {
persistentNodeLocalStorage.clear();
},
};
}
export function ensureGlobalNodeLocalStorage() {
const g = compatGlobal as unknown as Record<string, unknown>;
if (
!("localStorage" in g) ||
typeof (g.localStorage as Record<string, unknown> | undefined)?.getItem !== "function"
) {
g.localStorage = createNodeLocalStorageShim();
function isMissingFileError(error: unknown): boolean {
if (typeof error !== "object" || error === null) {
return false;
}
try {
const code = Object.getOwnPropertyDescriptor(error, "code");
return code !== undefined && Object.prototype.hasOwnProperty.call(code, "value") && code.value === "ENOENT";
} catch {
return false;
}
}
export function configureNodeLocalStorage(storagePath: string) {
persistentNodeLocalStorage.configure(storagePath);
ensureGlobalNodeLocalStorage();
}
export function getNodeLocalStorageItem(key: string): string {
return persistentNodeLocalStorage.getItem(key) ?? "";
}
export function setNodeLocalStorageItem(key: string, value: string) {
persistentNodeLocalStorage.setItem(key, value);
}
export function deleteNodeLocalStorageItem(key: string) {
persistentNodeLocalStorage.removeItem(key);
}
export function clearNodeLocalStorage() {
persistentNodeLocalStorage.clear();
export function createNodeLocalStorage(storagePath: string): NodeLocalStorage {
return createDeletableValueStore(new JsonFileValueBackend(storagePath));
}
@@ -1,58 +1,127 @@
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import { ValueStoreError } from "@vrtmrz/value-store";
import { afterEach, describe, expect, it } from "vitest";
import {
clearNodeLocalStorage,
configureNodeLocalStorage,
ensureGlobalNodeLocalStorage,
getNodeLocalStorageItem,
setNodeLocalStorageItem,
} from "./NodeLocalStorage";
import { createNodeLocalStorage } from "./NodeLocalStorage";
describe("NodeLocalStorage", () => {
const tempDirs: string[] = [];
afterEach(() => {
clearNodeLocalStorage();
for (const tempDir of tempDirs.splice(0)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("persists values to the configured file", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
configureNodeLocalStorage(storagePath);
setNodeLocalStorageItem("checkpoint", "42");
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
expect(saved.checkpoint).toBe("42");
});
it("reloads persisted values when configured again", () => {
it("loads and preserves legacy flat string data, including empty values and exact keys", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
const specialKey = "__proto__";
const specialValue = "line one\n雪";
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
fs.writeFileSync(storagePath, JSON.stringify({ persisted: "value" }, null, 2), "utf-8");
fs.writeFileSync(storagePath, JSON.stringify({ [specialKey]: specialValue, empty: "" }), "utf-8");
configureNodeLocalStorage(storagePath);
const store = createNodeLocalStorage(storagePath);
expect(getNodeLocalStorageItem("persisted")).toBe("value");
await expect(store.getValue(specialKey)).resolves.toBe(specialValue);
await expect(store.getValue("empty", "default")).resolves.toBe("");
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
expect(saved[specialKey]).toBe(specialValue);
expect(saved.empty).toBe("");
});
it("installs a global localStorage shim backed by the same store", () => {
it("persists strings and objects, reloads them, and deletes keys", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
const store = createNodeLocalStorage(storagePath);
const object = { enabled: false, entries: ["value", null, 0] };
configureNodeLocalStorage(storagePath);
ensureGlobalNodeLocalStorage();
await store.setValue("empty", "");
await store.setValue("text", "stored value");
await store.setObject("object", object);
globalThis.localStorage.setItem("shared", "state");
const reloaded = createNodeLocalStorage(storagePath);
await expect(reloaded.getValue("empty", "default")).resolves.toBe("");
await expect(reloaded.getValue("text")).resolves.toBe("stored value");
await expect(reloaded.getObject("object")).resolves.toEqual(object);
expect(getNodeLocalStorageItem("shared")).toBe("state");
await reloaded.delete("text");
await expect(reloaded.getValue("text")).resolves.toBeNull();
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
expect(saved.text).toBeUndefined();
expect(saved.object).toBe(JSON.stringify(object));
});
it("treats a missing file as empty and creates its parent on the first write", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
const store = createNodeLocalStorage(storagePath);
await expect(store.getValue("absent")).resolves.toBeNull();
expect(fs.existsSync(storagePath)).toBe(false);
await store.setValue("created", "value");
expect(fs.existsSync(storagePath)).toBe(true);
});
it("keeps separate store instances isolated by their file paths", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const first = createNodeLocalStorage(path.join(tempDir, "first", "local-storage.json"));
const second = createNodeLocalStorage(path.join(tempDir, "second", "local-storage.json"));
await first.setValue("shared-key", "first");
await second.setValue("shared-key", "second");
await expect(first.getValue("shared-key")).resolves.toBe("first");
await expect(second.getValue("shared-key")).resolves.toBe("second");
});
it("surfaces malformed and unreadable files as sanitised value-store errors", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const malformedPath = path.join(tempDir, "malformed.json");
fs.writeFileSync(malformedPath, "{ malformed", "utf-8");
let malformedError: unknown;
try {
createNodeLocalStorage(malformedPath);
} catch (error) {
malformedError = error;
}
expect(malformedError).toBeInstanceOf(ValueStoreError);
expect(malformedError).toMatchObject({ code: "invalid-data" });
expect(fs.readFileSync(malformedPath, "utf-8")).toBe("{ malformed");
const unreadablePath = path.join(tempDir, "unreadable.json");
fs.mkdirSync(unreadablePath);
expect(() => createNodeLocalStorage(unreadablePath)).toThrowError(ValueStoreError);
});
it("does not change its visible values when a write fails", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
const store = createNodeLocalStorage(storagePath);
await store.setValue("retained", "before failure");
fs.rmSync(storagePath);
fs.mkdirSync(storagePath);
await expect(store.setValue("new", "write fails")).rejects.toBeInstanceOf(ValueStoreError);
await expect(store.getValue("retained")).resolves.toBe("before failure");
await expect(store.getValue("new")).resolves.toBeNull();
});
it("does not install or modify a global localStorage value", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const before = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
createNodeLocalStorage(path.join(tempDir, "runtime", "local-storage.json"));
expect(Object.getOwnPropertyDescriptor(globalThis, "localStorage")).toEqual(before);
});
});
+8 -11
View File
@@ -1,6 +1,5 @@
import type { AppLifecycleServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/ConfigServiceBrowserCompat";
import type {
ComponentHasResult,
SvelteDialogManager,
@@ -11,6 +10,7 @@ import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService";
import { InjectableFileProcessingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableFileProcessingService";
import { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService";
import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService";
@@ -21,7 +21,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import { HeadlessAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/headless/HeadlessAPIService";
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
import { NodeSettingService } from "./NodeSettingService";
import { createNodeLocalStorage } from "./NodeLocalStorage";
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { path as nodePath } from "@vrtmrz/livesync-commonlib/node";
@@ -96,11 +96,11 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
const conflict = new InjectableConflictService(context);
const fileProcessing = new InjectableFileProcessingService(context);
const setting = new NodeSettingService(
context,
{ APIService: API, onDisplayLanguageChanged: setLang },
localStoragePath
);
const setting = new InjectableSettingService<T>(context, {
APIService: API,
onDisplayLanguageChanged: setLang,
deviceValues: createNodeLocalStorage(localStoragePath),
});
const appLifecycle = new NodeAppLifecycleService<T>(context, {
settingService: setting,
@@ -132,10 +132,7 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
setting,
});
const config = new ConfigServiceBrowserCompat<T>(context, {
settingService: setting,
APIService: API,
});
const config = setting.config;
const replicator = new InjectableReplicatorService(context, {
settingService: setting,
@@ -1,42 +0,0 @@
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { EVENT_REQUEST_RELOAD_SETTING_TAB } from "@/common/events";
import { handlers } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { SettingService, type SettingServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import {
configureNodeLocalStorage,
deleteNodeLocalStorageItem,
getNodeLocalStorageItem,
setNodeLocalStorageItem,
} from "./NodeLocalStorage";
export class NodeSettingService<T extends ServiceContext> extends SettingService<T> {
constructor(context: T, dependencies: SettingServiceDependencies, storagePath: string) {
super(context, dependencies);
configureNodeLocalStorage(storagePath);
this.onSettingSaved.addHandler((settings) => {
this.context.events.emitEvent(EVENT_SETTING_SAVED, settings);
return Promise.resolve(true);
});
this.onSettingLoaded.addHandler((settings) => {
this.context.events.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
return Promise.resolve(true);
});
}
protected setItem(key: string, value: string) {
setNodeLocalStorageItem(key, value);
}
protected getItem(key: string): string {
return getNodeLocalStorageItem(key);
}
protected deleteItem(key: string): void {
deleteNodeLocalStorageItem(key);
}
public saveData = handlers<{ saveData: (data: ObsidianLiveSyncSettings) => Promise<void> }>().binder("saveData");
public loadData = handlers<{ loadData: () => Promise<ObsidianLiveSyncSettings | undefined> }>().binder("loadData");
}