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
View File
@@ -309,6 +309,8 @@ Existing legacy Modules continue to register their handlers in `onBindFunction()
database and Replicator readiness. See database and Replicator readiness. See
[the declarative settings adapter ADR](docs/adr/2026_08_declarative_settings_adapter.md). [the declarative settings adapter ADR](docs/adr/2026_08_declarative_settings_adapter.md).
- Use `this.services.setting.saveSettingData()` instead of using plugin methods directly - Use `this.services.setting.saveSettingData()` instead of using plugin methods directly
- Inject `@vrtmrz/value-store` at host composition. `setting.deviceValues` uses exact device-local keys; `setting.config` and the Service Hub `config` share the existing Vault and database-suffix scope. Await reads, writes, and deletions before advancing the workflow. Browser and Obsidian hosts retain their Web Storage keys, and the CLI retains its existing flat JSON string map.
- Seed the settings tab with `await settingTab.loadSettings()` before registration. Its editing buffer remains synchronous for Obsidian rendering; storage failures must not replace a previously loaded value with an empty default.
### Database Operations ### Database Operations
+14 -4
View File
@@ -23,9 +23,10 @@
"@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.44", "@vrtmrz/livesync-commonlib": "0.2.0",
"@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",
"@vrtmrz/value-store": "0.1.0",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"idb": "^8.0.3", "idb": "^8.0.3",
"markdown-it": "^14.3.2", "markdown-it": "^14.3.2",
@@ -4115,9 +4116,9 @@
} }
}, },
"node_modules/@vrtmrz/livesync-commonlib": { "node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.44", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.44.tgz", "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.2.0.tgz",
"integrity": "sha512-GjXNSLTnWLZzdgrhW3sIOsLqGLP4iMgypPMtcBc2Ju2vMq64TRA1Llfoya1KRIeu6m0G7Xu3ZtkWARTRcHkYYg==", "integrity": "sha512-K24Pkke+FTjZI7DjIV7qkZk+BmC9VebN3da+2cVD9SWOgoGl7OvDyXTHDafbrmmM4vi4ydaydAEh9glO1xHF6A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@aws-crypto/sha256-js": "5.2.0", "@aws-crypto/sha256-js": "5.2.0",
@@ -4128,6 +4129,7 @@
"@smithy/types": "^4.14.3", "@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@smithy/util-retry": "^4.4.5",
"@trystero-p2p/nostr": "0.25.3", "@trystero-p2p/nostr": "0.25.3",
"@vrtmrz/value-store": "0.1.0",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"events": "^3.3.0", "events": "^3.3.0",
"fflate": "^0.8.2", "fflate": "^0.8.2",
@@ -4139,9 +4141,11 @@
"pouchdb-adapter-idb": "^9.0.0", "pouchdb-adapter-idb": "^9.0.0",
"pouchdb-adapter-indexeddb": "^9.0.0", "pouchdb-adapter-indexeddb": "^9.0.0",
"pouchdb-adapter-memory": "^9.0.0", "pouchdb-adapter-memory": "^9.0.0",
"pouchdb-checkpointer": "9.0.0",
"pouchdb-core": "^9.0.0", "pouchdb-core": "^9.0.0",
"pouchdb-errors": "^9.0.0", "pouchdb-errors": "^9.0.0",
"pouchdb-find": "^9.0.0", "pouchdb-find": "^9.0.0",
"pouchdb-generate-replication-id": "9.0.0",
"pouchdb-mapreduce": "^9.0.0", "pouchdb-mapreduce": "^9.0.0",
"pouchdb-merge": "^9.0.0", "pouchdb-merge": "^9.0.0",
"pouchdb-replication": "^9.0.0", "pouchdb-replication": "^9.0.0",
@@ -4210,6 +4214,12 @@
"integrity": "sha512-njT2BSFh57imwGDxWXJOcGbZOYFVNGmeXMrS7/RUdBoAeOjWzJB9TxI7WcR1sgQIXKh8TE9hqALU8lue5b8dRw==", "integrity": "sha512-njT2BSFh57imwGDxWXJOcGbZOYFVNGmeXMrS7/RUdBoAeOjWzJB9TxI7WcR1sgQIXKh8TE9hqALU8lue5b8dRw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vrtmrz/value-store": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@vrtmrz/value-store/-/value-store-0.1.0.tgz",
"integrity": "sha512-M62f1j0bnJkqpgI5sYxnkXUAOSDEwQbYrLf+w58gCCm4sjIXDmFzDJd2duXabRbktBlACt9P99yjN/9gOAPxrA==",
"license": "MIT"
},
"node_modules/abstract-leveldown": { "node_modules/abstract-leveldown": {
"version": "6.2.3", "version": "6.2.3",
"resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz", "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz",
+2 -1
View File
@@ -193,9 +193,10 @@
"@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.44", "@vrtmrz/livesync-commonlib": "0.2.0",
"@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",
"@vrtmrz/value-store": "0.1.0",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"idb": "^8.0.3", "idb": "^8.0.3",
"markdown-it": "^14.3.2", "markdown-it": "^14.3.2",
+2 -2
View File
@@ -1,6 +1,6 @@
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService"; import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService"; import type { IConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService"; import type { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService"; import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService"; import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService";
@@ -11,7 +11,7 @@ import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> { export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
API: InjectableAPIService<T>; API: InjectableAPIService<T>;
appLifecycle: AppLifecycleService<T>; appLifecycle: AppLifecycleService<T>;
config: ConfigService<T>; config: IConfigService;
control: ControlService<T>; control: ControlService<T>;
replicator: ReplicatorService<T>; replicator: ReplicatorService<T>;
} }
@@ -1,11 +1,10 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { KeyValueDatabaseFactory } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase"; import type { KeyValueDatabaseFactory } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser"; import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
import { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService"; import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService"; import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService"; import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
import type { ISettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; import type { ValueDeleter, ValueStore } from "@vrtmrz/value-store";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService"; import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService";
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService"; import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
@@ -24,10 +23,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
import { setLang, translateLiveSyncMessage } from "@/common/translation"; import { setLang, translateLiveSyncMessage } from "@/common/translation";
import { BrowserConfirm } from "./BrowserConfirm"; import { BrowserConfirm } from "./BrowserConfirm";
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase"; import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
import { import { LiveSyncBrowserAPIService, type LiveSyncBrowserAPIServiceOptions } from "./LiveSyncBrowserAPIService";
LiveSyncBrowserAPIService,
type LiveSyncBrowserAPIServiceOptions,
} from "./LiveSyncBrowserAPIService";
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService"; import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
export interface LiveSyncBrowserSettingsPersistence { export interface LiveSyncBrowserSettingsPersistence {
@@ -43,6 +39,7 @@ export interface LiveSyncBrowserRestartPolicy {
} }
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> { export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
deviceValues: ValueStore & ValueDeleter;
context?: T; context?: T;
getSystemVaultName?: () => string; getSystemVaultName?: () => string;
settings?: LiveSyncBrowserSettingsPersistence; settings?: LiveSyncBrowserSettingsPersistence;
@@ -51,35 +48,12 @@ export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
API?: Omit<LiveSyncBrowserAPIServiceOptions, "confirm" | "getSystemVaultName">; API?: Omit<LiveSyncBrowserAPIServiceOptions, "confirm" | "getSystemVaultName">;
} }
class LiveSyncBrowserAppLifecycleService< class LiveSyncBrowserAppLifecycleService<T extends ServiceContext> extends InjectableAppLifecycleService<T> {}
T extends ServiceContext,
> extends InjectableAppLifecycleService<T> {}
class LiveSyncBrowserDatabaseService<T extends ServiceContext> extends DatabaseService<T> {} class LiveSyncBrowserDatabaseService<T extends ServiceContext> extends DatabaseService<T> {}
class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValueDBService<T> {} class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValueDBService<T> {}
class LiveSyncBrowserConfigService<T extends ServiceContext> extends ConfigService<T> {
constructor(
context: T,
private readonly setting: ISettingService
) {
super(context);
}
getSmallConfig(key: string): string | null {
return this.setting.getSmallConfig(key);
}
setSmallConfig(key: string, value: string): void {
this.setting.setSmallConfig(key, value);
}
deleteSmallConfig(key: string): void {
this.setting.deleteSmallConfig(key);
}
}
/** LiveSync-owned service composition shared by WebApp and WebPeer. */ /** LiveSync-owned service composition shared by WebApp and WebPeer. */
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> { export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
constructor(options: LiveSyncBrowserServiceHubOptions<T>) { constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
@@ -98,17 +72,14 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
const setting = new InjectableSettingService(context, { const setting = new InjectableSettingService(context, {
APIService: API, APIService: API,
onDisplayLanguageChanged: setLang, onDisplayLanguageChanged: setLang,
deviceValues: options.deviceValues,
}); });
const settingsPersistence = options.settings; const settingsPersistence = options.settings;
setting.loadData.setHandler( setting.loadData.setHandler(
settingsPersistence settingsPersistence ? () => settingsPersistence.load() : () => Promise.resolve(undefined)
? () => settingsPersistence.load()
: () => Promise.resolve(undefined)
); );
setting.saveData.setHandler( setting.saveData.setHandler(
settingsPersistence settingsPersistence ? (settings) => settingsPersistence.save(settings) : () => Promise.resolve()
? (settings) => settingsPersistence.save(settings)
: () => Promise.resolve()
); );
const appLifecycle = new LiveSyncBrowserAppLifecycleService(context, { const appLifecycle = new LiveSyncBrowserAppLifecycleService(context, {
@@ -142,7 +113,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
setting, setting,
API, API,
}); });
const config = new LiveSyncBrowserConfigService(context, setting); const config = setting.config;
const replicator = new InjectableReplicatorService(context, { const replicator = new InjectableReplicatorService(context, {
settingService: setting, settingService: setting,
appLifecycleService: appLifecycle, appLifecycleService: appLifecycle,
@@ -163,8 +134,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
databaseService: database, databaseService: database,
}); });
const keyValueDB = new LiveSyncBrowserKeyValueDBService(context, { const keyValueDB = new LiveSyncBrowserKeyValueDBService(context, {
openKeyValueDatabase: openKeyValueDatabase: options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
appLifecycle, appLifecycle,
databaseEvents, databaseEvents,
vault, vault,
@@ -209,7 +179,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
} }
export function createLiveSyncBrowserServiceHub<T extends ServiceContext>( export function createLiveSyncBrowserServiceHub<T extends ServiceContext>(
options: LiveSyncBrowserServiceHubOptions<T> = {} options: LiveSyncBrowserServiceHubOptions<T>
): LiveSyncBrowserServiceHub<T> { ): LiveSyncBrowserServiceHub<T> {
return new LiveSyncBrowserServiceHub(options); return new LiveSyncBrowserServiceHub(options);
} }
@@ -1,5 +1,6 @@
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { MemoryValueStore } from "@vrtmrz/value-store";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
@@ -21,7 +22,8 @@ describe("LiveSync browser service context contract", () => {
const context = createServiceContext({ const context = createServiceContext({
translate: (key) => `webapp:${key}`, translate: (key) => `webapp:${key}`,
}); });
const hub = createLiveSyncBrowserServiceHub({ context }); const deviceValues = new MemoryValueStore();
const hub = createLiveSyncBrowserServiceHub({ context, deviceValues });
expect(observeServiceContext(context, "moduleLocalDatabase.logWaitingForReady")).toEqual({ expect(observeServiceContext(context, "moduleLocalDatabase.logWaitingForReady")).toEqual({
translation: "webapp:moduleLocalDatabase.logWaitingForReady", translation: "webapp:moduleLocalDatabase.logWaitingForReady",
@@ -32,20 +34,17 @@ describe("LiveSync browser service context contract", () => {
expect(SERVICE_CONTEXT_MEMBERS.filter((member) => !composition.servicesUsingExpectedContext[member])).toEqual( expect(SERVICE_CONTEXT_MEMBERS.filter((member) => !composition.servicesUsingExpectedContext[member])).toEqual(
[] []
); );
expect(hub.setting.deviceValues).toBe(deviceValues);
expect(hub.config).toBe(hub.setting.config);
}); });
it("binds browser identity, persistence, restart policy, and native Fetch while composing the hub", async () => { it("binds browser identity, persistence, restart policy, and native Fetch while composing the hub", async () => {
const deviceLocalSettings = new Map<string, string>(); const deviceValues = new MemoryValueStore();
vi.stubGlobal("localStorage", {
getItem: (key: string) => deviceLocalSettings.get(key) ?? null,
setItem: (key: string, value: string) => deviceLocalSettings.set(key, value),
removeItem: (key: string) => deviceLocalSettings.delete(key),
clear: () => deviceLocalSettings.clear(),
});
const savedSettings: (typeof DEFAULT_SETTINGS)[] = []; const savedSettings: (typeof DEFAULT_SETTINGS)[] = [];
const restartCalls: string[] = []; const restartCalls: string[] = [];
const nativeFetch = vi.fn(async () => new Response("ok")); const nativeFetch = vi.fn(async () => new Response("ok"));
const options: LiveSyncBrowserServiceHubOptions<ReturnType<typeof createServiceContext>> = { const options: LiveSyncBrowserServiceHubOptions<ReturnType<typeof createServiceContext>> = {
deviceValues,
getSystemVaultName: () => "browser-vault", getSystemVaultName: () => "browser-vault",
settings: { settings: {
load: async () => ({ load: async () => ({
@@ -69,6 +68,8 @@ describe("LiveSync browser service context contract", () => {
const hub = createLiveSyncBrowserServiceHub(options); const hub = createLiveSyncBrowserServiceHub(options);
expect(hub.setting.deviceValues).toBe(deviceValues);
expect(hub.config).toBe(hub.setting.config);
expect(hub.API.getSystemVaultName()).toBe("browser-vault"); expect(hub.API.getSystemVaultName()).toBe("browser-vault");
expect(hub.API.getPlatform()).toBe("browser"); expect(hub.API.getPlatform()).toBe("browser");
const response = await hub.API.nativeFetch("https://example.invalid/"); const response = await hub.API.nativeFetch("https://example.invalid/");
@@ -86,4 +87,30 @@ describe("LiveSync browser service context contract", () => {
expect(hub.appLifecycle.isReloadingScheduled()).toBe(true); expect(hub.appLifecycle.isReloadingScheduled()).toBe(true);
expect(restartCalls).toEqual(["schedule", "perform", "ask:restart now"]); expect(restartCalls).toEqual(["schedule", "perform", "ask:restart now"]);
}); });
it("keeps each browser composition bound to its own device value store", async () => {
const firstValues = new MemoryValueStore();
const secondValues = new MemoryValueStore();
const firstHub = createLiveSyncBrowserServiceHub({
deviceValues: firstValues,
getSystemVaultName: () => "first-vault",
});
const secondHub = createLiveSyncBrowserServiceHub({
deviceValues: secondValues,
getSystemVaultName: () => "second-vault",
});
await firstHub.config.setValue("probe", "first");
await secondHub.config.setValue("probe", "second");
expect(firstHub.setting.deviceValues).toBe(firstValues);
expect(secondHub.setting.deviceValues).toBe(secondValues);
expect(firstHub.config).toBe(firstHub.setting.config);
expect(secondHub.config).toBe(secondHub.setting.config);
expect(firstHub.config).not.toBe(secondHub.config);
await expect(firstHub.config.getValue("probe")).resolves.toBe("first");
await expect(secondHub.config.getValue("probe")).resolves.toBe("second");
await expect(firstValues.getValue("first-vault-probe")).resolves.toBe("first");
await expect(secondValues.getValue("second-vault-probe")).resolves.toBe("second");
});
}); });
+2 -2
View File
@@ -719,8 +719,8 @@ src/apps/cli/
│ └── ServiceFileAccessImpl.ts │ └── ServiceFileAccessImpl.ts
├── services/ ├── services/
│ ├── NodeKeyValueDBService.ts │ ├── NodeKeyValueDBService.ts
│ ├── NodeServiceHub.ts │ ├── NodeLocalStorage.ts
│ └── NodeSettingService.ts │ └── NodeServiceHub.ts
├── test/ ├── test/
│ ├── test-e2e-two-vaults-common.sh │ ├── test-e2e-two-vaults-common.sh
│ ├── test-e2e-two-vaults-matrix.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}`); 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])( it.each([REMOTE_P2P, REMOTE_COUCHDB])(
"restores configured P2P automation before the host connects (%s)", "restores configured P2P automation before the host connects (%s)",
async (remoteType) => { async (remoteType) => {
-4
View File
@@ -1,6 +1,5 @@
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation"; import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub"; import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore"; import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore";
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules"; import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
import { import {
@@ -78,7 +77,6 @@ const VAULT_SYNC_MODES: Readonly<Partial<Record<CLICommand, CLIVaultSyncMode>>>
}, },
}; };
ensureGlobalNodeLocalStorage();
defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG; defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
/** Injectable command boundary used by CLI integration probes. */ /** Injectable command boundary used by CLI integration probes. */
@@ -400,8 +398,6 @@ export async function main(
const settingsPath = options.settingsPath const settingsPath = options.settingsPath
? path.resolve(options.settingsPath) ? path.resolve(options.settingsPath)
: path.join(databasePath, SETTINGS_FILE); : path.join(databasePath, SETTINGS_FILE);
configureNodeLocalStorage(path.join(databasePath, ".livesync", "runtime", "local-storage.json"));
// Resolve vault path: mirror positional argument takes priority, // Resolve vault path: mirror positional argument takes priority,
// then --vault flag, otherwise fall back to databasePath. // then --vault flag, otherwise fall back to databasePath.
const vaultPath = const vaultPath =
+1 -1
View File
@@ -12,7 +12,7 @@
"buildRun": "npm run build && npm run cli --", "buildRun": "npm run build && npm run cli --",
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..", "build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
"check": "tsc -p tsconfig.json", "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": "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:common": "bash test/test-e2e-two-vaults-common.sh",
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.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 { fs as nodeFs, path as nodePath } from "@vrtmrz/livesync-commonlib/node";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
type LocalStorageShape = { type NodeLocalStorage = ValueStore & ValueDeleter;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
clear(): void;
};
class PersistentNodeLocalStorage { class JsonFileValueBackend {
private storagePath: string | undefined; private values: Map<string, string>;
private localStore: Record<string, string> = {};
configure(storagePath: string) { constructor(private readonly storagePath: string) {
if (this.storagePath === storagePath) { this.values = this.loadValues();
return;
}
this.storagePath = storagePath;
this.loadFromFile();
} }
private loadFromFile() { get(key: string): string | null {
if (!this.storagePath) { return this.values.get(key) ?? null;
this.localStore = {}; }
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; 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 { try {
const loaded = JSON.parse(nodeFs.readFileSync(this.storagePath, "utf-8")) as Record<string, string>; contents = nodeFs.readFileSync(this.storagePath, "utf-8");
this.localStore = { ...loaded }; } catch (error) {
if (isMissingFileError(error)) {
return new Map();
}
throw sanitiseValueStoreError(error);
}
let document: unknown;
try {
document = JSON.parse(contents);
} catch { } 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() { private writeValues(values: ReadonlyMap<string, string>): void {
if (!this.storagePath) {
return;
}
nodeFs.mkdirSync(nodePath.dirname(this.storagePath), { recursive: true }); nodeFs.mkdirSync(nodePath.dirname(this.storagePath), { recursive: true });
nodeFs.writeFileSync(this.storagePath, JSON.stringify(this.localStore, null, 2), "utf-8"); nodeFs.writeFileSync(this.storagePath, JSON.stringify(Object.fromEntries(values), 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();
} }
} }
const persistentNodeLocalStorage = new PersistentNodeLocalStorage(); function isMissingFileError(error: unknown): boolean {
if (typeof error !== "object" || error === null) {
function createNodeLocalStorageShim(): LocalStorageShape { return false;
return { }
getItem(key: string) { try {
return persistentNodeLocalStorage.getItem(key); const code = Object.getOwnPropertyDescriptor(error, "code");
}, return code !== undefined && Object.prototype.hasOwnProperty.call(code, "value") && code.value === "ENOENT";
setItem(key: string, value: string) { } catch {
persistentNodeLocalStorage.setItem(key, value); return false;
},
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();
} }
} }
export function configureNodeLocalStorage(storagePath: string) { export function createNodeLocalStorage(storagePath: string): NodeLocalStorage {
persistentNodeLocalStorage.configure(storagePath); return createDeletableValueStore(new JsonFileValueBackend(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();
} }
@@ -1,58 +1,127 @@
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node"; import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import { ValueStoreError } from "@vrtmrz/value-store";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { import { createNodeLocalStorage } from "./NodeLocalStorage";
clearNodeLocalStorage,
configureNodeLocalStorage,
ensureGlobalNodeLocalStorage,
getNodeLocalStorageItem,
setNodeLocalStorageItem,
} from "./NodeLocalStorage";
describe("NodeLocalStorage", () => { describe("NodeLocalStorage", () => {
const tempDirs: string[] = []; const tempDirs: string[] = [];
afterEach(() => { afterEach(() => {
clearNodeLocalStorage();
for (const tempDir of tempDirs.splice(0)) { for (const tempDir of tempDirs.splice(0)) {
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
} }
}); });
it("persists values to the configured file", () => { 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");
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", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-")); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir); tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json"); 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.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-")); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir); tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json"); const storagePath = path.join(tempDir, "runtime", "local-storage.json");
const store = createNodeLocalStorage(storagePath);
const object = { enabled: false, entries: ["value", null, 0] };
configureNodeLocalStorage(storagePath); await store.setValue("empty", "");
ensureGlobalNodeLocalStorage(); 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 { AppLifecycleServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/ConfigServiceBrowserCompat";
import type { import type {
ComponentHasResult, ComponentHasResult,
SvelteDialogManager, SvelteDialogManager,
@@ -11,6 +10,7 @@ import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService"; import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService"; import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService";
import { InjectableFileProcessingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableFileProcessingService"; 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 { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService"; import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService";
import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService"; 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 { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import { HeadlessAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/headless/HeadlessAPIService"; import { HeadlessAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/headless/HeadlessAPIService";
import { NodeKeyValueDBService } from "./NodeKeyValueDBService"; import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
import { NodeSettingService } from "./NodeSettingService"; import { createNodeLocalStorage } from "./NodeLocalStorage";
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService"; import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { path as nodePath } from "@vrtmrz/livesync-commonlib/node"; 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 conflict = new InjectableConflictService(context);
const fileProcessing = new InjectableFileProcessingService(context); const fileProcessing = new InjectableFileProcessingService(context);
const setting = new NodeSettingService( const setting = new InjectableSettingService<T>(context, {
context, APIService: API,
{ APIService: API, onDisplayLanguageChanged: setLang }, onDisplayLanguageChanged: setLang,
localStoragePath deviceValues: createNodeLocalStorage(localStoragePath),
); });
const appLifecycle = new NodeAppLifecycleService<T>(context, { const appLifecycle = new NodeAppLifecycleService<T>(context, {
settingService: setting, settingService: setting,
@@ -132,10 +132,7 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
setting, setting,
}); });
const config = new ConfigServiceBrowserCompat<T>(context, { const config = setting.config;
settingService: setting,
APIService: API,
});
const replicator = new InjectableReplicatorService(context, { const replicator = new InjectableReplicatorService(context, {
settingService: setting, 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");
}
+12 -2
View File
@@ -21,6 +21,8 @@ import { useCheckRemoteSize } from "@vrtmrz/livesync-commonlib/compat/serviceFea
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig"; import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature"; import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
import type { ValueStore, ValueDeleter } from "@vrtmrz/value-store";
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { import {
createLiveSyncBrowserServiceHub, createLiveSyncBrowserServiceHub,
@@ -56,6 +58,7 @@ const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success"; export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success";
export interface WebAppRuntimeOptions { export interface WebAppRuntimeOptions {
deviceValues?: ValueStore & ValueDeleter;
reportStatus?: (kind: WebAppRuntimeStatusKind, message: string) => void; reportStatus?: (kind: WebAppRuntimeStatusKind, message: string) => void;
scheduleReload?: (delayMilliseconds: number) => void; scheduleReload?: (delayMilliseconds: number) => void;
} }
@@ -71,7 +74,10 @@ export class WebAppRuntime {
private paneHost: P2PReplicatorPaneHost | null = null; private paneHost: P2PReplicatorPaneHost | null = null;
private restartScheduled = false; private restartScheduled = false;
constructor(rootHandle: FileSystemDirectoryHandle, options: WebAppRuntimeOptions = {}) { constructor(
rootHandle: FileSystemDirectoryHandle,
private readonly options: WebAppRuntimeOptions = {}
) {
this.rootHandle = rootHandle; this.rootHandle = rootHandle;
this.reportStatus = options.reportStatus ?? (() => {}); this.reportStatus = options.reportStatus ?? (() => {});
this.scheduleReload = this.scheduleReload =
@@ -89,6 +95,8 @@ export class WebAppRuntime {
private createServiceOptions(): LiveSyncBrowserServiceHubOptions<ServiceContext> { private createServiceOptions(): LiveSyncBrowserServiceHubOptions<ServiceContext> {
return { return {
deviceValues:
this.options.deviceValues ?? createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
getSystemVaultName: () => this.rootHandle.name || "livesync-webapp", getSystemVaultName: () => this.rootHandle.name || "livesync-webapp",
settings: { settings: {
save: async (data) => { save: async (data) => {
@@ -219,7 +227,9 @@ export class WebAppRuntime {
useCheckRemoteSize(core); useCheckRemoteSize(core);
useRemoteConfiguration(core); useRemoteConfiguration(core);
this.p2p = useP2PReplicatorFeature(core, undefined, undefined, { this.p2p = useP2PReplicatorFeature(core, undefined, undefined, {
prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)), prepareP2PSettings: useP2PSettingsPreparation(
core.services.API.webCompatFetch.bind(core.services.API)
),
}); });
this.paneHost = { this.paneHost = {
services: core.services, services: core.services,
+7 -3
View File
@@ -7,6 +7,8 @@ import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/t
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature"; import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context"; import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p"; import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
import type { ValueStore, ValueDeleter } from "@vrtmrz/value-store";
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
import { unique } from "octagonal-wheels/collection"; import { unique } from "octagonal-wheels/collection";
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
@@ -20,6 +22,7 @@ import { translateLiveSyncMessage } from "@/common/translation";
import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersistence"; import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersistence";
export interface WebPeerRuntimeOptions { export interface WebPeerRuntimeOptions {
deviceValues?: ValueStore & ValueDeleter;
context?: ServiceContext; context?: ServiceContext;
store?: SimpleStore<unknown>; store?: SimpleStore<unknown>;
deviceName?: string; deviceName?: string;
@@ -62,6 +65,7 @@ export class WebPeerRuntime {
this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage }); this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage });
this.services = createLiveSyncBrowserServiceHub<ServiceContext>({ this.services = createLiveSyncBrowserServiceHub<ServiceContext>({
context: this.context, context: this.context,
deviceValues: options.deviceValues ?? createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
getSystemVaultName: () => options.systemVaultName ?? WEBPEER_STORE_NAME, getSystemVaultName: () => options.systemVaultName ?? WEBPEER_STORE_NAME,
settings: persistence.settings, settings: persistence.settings,
restart: { restart: {
@@ -103,7 +107,7 @@ export class WebPeerRuntime {
await this.services.setting.loadSettings(); await this.services.setting.loadSettings();
const deviceName = this.options.deviceName?.trim(); const deviceName = this.options.deviceName?.trim();
if (deviceName) { if (deviceName) {
this.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, deviceName); await this.services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, deviceName);
} }
const opened = await this.services.database.openDatabase({ const opened = await this.services.database.openDatabase({
replicator: this.services.replicator, replicator: this.services.replicator,
@@ -192,7 +196,7 @@ export class WebPeerRuntime {
); );
} }
getDeviceName(): string { async getDeviceName(): Promise<string> {
return this.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || this.services.vault.getVaultName(); return (await this.services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME)) || this.services.vault.getVaultName();
} }
} }
+8
View File
@@ -28,6 +28,8 @@ import {
getObjectStorageReplicatorConfigurationIdentity, getObjectStorageReplicatorConfigurationIdentity,
} from "./replicatorConfigurationIdentity"; } from "./replicatorConfigurationIdentity";
import { import {
createCouchDBJoinedDevicesResourceFactory,
createObjectStorageJoinedDevicesResourceFactory,
createCouchDBConnectionProbeFactory, createCouchDBConnectionProbeFactory,
createCouchDBPreferredTweakProbeFactory, createCouchDBPreferredTweakProbeFactory,
createCouchDBSecuritySeedResourceFactory, createCouchDBSecuritySeedResourceFactory,
@@ -115,6 +117,9 @@ export function createCentralReplicatorProviderDefinitions(
configurationIdentity: getCouchDBReplicatorConfigurationIdentity, configurationIdentity: getCouchDBReplicatorConfigurationIdentity,
create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)), create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)),
remoteResources: { remoteResources: {
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
createCouchDBJoinedDevicesResourceFactory(host)
),
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)), [REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)),
[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability( [REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability(
createCouchDBPreferredTweakProbeFactory(host) createCouchDBPreferredTweakProbeFactory(host)
@@ -141,6 +146,9 @@ export function createCentralReplicatorProviderDefinitions(
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity, configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
create: () => Promise.resolve(new LiveSyncJournalReplicator(host)), create: () => Promise.resolve(new LiveSyncJournalReplicator(host)),
remoteResources: { remoteResources: {
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
createObjectStorageJoinedDevicesResourceFactory(host)
),
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability( [REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(
createObjectStorageConnectionProbeFactory(host) createObjectStorageConnectionProbeFactory(host)
), ),
+19 -3
View File
@@ -44,7 +44,16 @@ describe("central Replicator provider definitions", () => {
it("keeps the retained remote-resource catalogue bounded", () => { it("keeps the retained remote-resource catalogue bounded", () => {
expect expect
.soft(Object.values(REMOTE_RESOURCE_KINDS).sort()) .soft(Object.values(REMOTE_RESOURCE_KINDS).sort())
.toEqual(["connection", "preferred-tweak", "security-seed", "synchronisation-information"].sort()); .toEqual(
[
"connection",
"initial-fetch-fast-forward",
"joined-devices",
"preferred-tweak",
"security-seed",
"synchronisation-information",
].sort()
);
}); });
it("lets Journal prepare its own fresh Security Seed while CouchDB uses central preparation", () => { it("lets Journal prepare its own fresh Security Seed while CouchDB uses central preparation", () => {
@@ -106,8 +115,15 @@ describe("central Replicator provider definitions", () => {
const couchAdministration = definitions.get(REMOTE_COUCHDB)?.centralRemoteAdministration; const couchAdministration = definitions.get(REMOTE_COUCHDB)?.centralRemoteAdministration;
const objectAdministration = definitions.get(REMOTE_MINIO)?.centralRemoteAdministration; const objectAdministration = definitions.get(REMOTE_MINIO)?.centralRemoteAdministration;
expect(Object.keys(couchResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort()); const requiredResources = Object.values(REMOTE_RESOURCE_KINDS).filter(
expect(Object.keys(objectResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort()); (kind) => kind !== REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD
);
expect(Object.keys(couchResources ?? {}).sort()).toEqual(requiredResources.sort());
expect(Object.keys(objectResources ?? {}).sort()).toEqual(requiredResources.sort());
expect(couchResources?.[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(objectResources?.[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]).toBeUndefined();
expect(objectResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]).toBeUndefined();
expect(couchResources?.[REMOTE_RESOURCE_KINDS.CONNECTION].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); expect(couchResources?.[REMOTE_RESOURCE_KINDS.CONNECTION].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); expect(couchResources?.[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); expect(couchResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
getRemoteStatus: ReturnType<typeof vi.fn>; getRemoteStatus: ReturnType<typeof vi.fn>;
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>; getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>; getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
getJoinedDevices: ReturnType<typeof vi.fn>;
forgetJoinedDevice: ReturnType<typeof vi.fn>;
closeReplication: ReturnType<typeof vi.fn>; closeReplication: ReturnType<typeof vi.fn>;
}>, }>,
objectStorage: [] as Array<{ objectStorage: [] as Array<{
@@ -20,6 +22,8 @@ const mocks = vi.hoisted(() => ({
getRemoteStatus: ReturnType<typeof vi.fn>; getRemoteStatus: ReturnType<typeof vi.fn>;
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>; getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>; getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
getJoinedDevices: ReturnType<typeof vi.fn>;
forgetJoinedDevice: ReturnType<typeof vi.fn>;
closeReplication: ReturnType<typeof vi.fn>; closeReplication: ReturnType<typeof vi.fn>;
}>, }>,
checkSyncInfo: vi.fn(async () => true), checkSyncInfo: vi.fn(async () => true),
@@ -42,6 +46,8 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicato
getRemoteStatus = vi.fn(); getRemoteStatus = vi.fn();
getRemotePreferredTweakValues = vi.fn(); getRemotePreferredTweakValues = vi.fn();
getReplicationPBKDF2Salt = vi.fn(); getReplicationPBKDF2Salt = vi.fn();
getJoinedDevices = vi.fn();
forgetJoinedDevice = vi.fn();
closeReplication = vi.fn(); closeReplication = vi.fn();
constructor(host: unknown) { constructor(host: unknown) {
@@ -58,6 +64,8 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
getRemoteStatus = vi.fn(); getRemoteStatus = vi.fn();
getRemotePreferredTweakValues = vi.fn(); getRemotePreferredTweakValues = vi.fn();
getReplicationPBKDF2Salt = vi.fn(); getReplicationPBKDF2Salt = vi.fn();
getJoinedDevices = vi.fn();
forgetJoinedDevice = vi.fn();
closeReplication = vi.fn(); closeReplication = vi.fn();
constructor(host: unknown) { constructor(host: unknown) {
@@ -69,10 +77,12 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
import { import {
createCouchDBConnectionProbeFactory, createCouchDBConnectionProbeFactory,
createCouchDBJoinedDevicesResourceFactory,
createCouchDBPreferredTweakProbeFactory, createCouchDBPreferredTweakProbeFactory,
createCouchDBSecuritySeedResourceFactory, createCouchDBSecuritySeedResourceFactory,
createCouchDBSynchronisationInformationResourceFactory, createCouchDBSynchronisationInformationResourceFactory,
createObjectStorageConnectionProbeFactory, createObjectStorageConnectionProbeFactory,
createObjectStorageJoinedDevicesResourceFactory,
createObjectStoragePreferredTweakProbeFactory, createObjectStoragePreferredTweakProbeFactory,
createObjectStorageSecuritySeedResourceFactory, createObjectStorageSecuritySeedResourceFactory,
} from "./replicatorResources"; } from "./replicatorResources";
@@ -226,6 +236,65 @@ describe("replicator probe factories", () => {
expect(preferredReplicator.getRemotePreferredTweakValues).toHaveBeenCalledWith(snapshot); expect(preferredReplicator.getRemotePreferredTweakValues).toHaveBeenCalledWith(snapshot);
}); });
it.each([
{
kind: "CouchDB",
remoteType: REMOTE_COUCHDB,
createFactory: createCouchDBJoinedDevicesResourceFactory,
instances: () => mocks.couchDB,
},
{
kind: "Object Storage",
remoteType: REMOTE_MINIO,
createFactory: createObjectStorageJoinedDevicesResourceFactory,
instances: () => mocks.objectStorage,
},
])(
"keeps $kind joined-device operations on an owned settings snapshot",
async ({ remoteType, createFactory, instances }) => {
const host = { name: "host" };
const source = createSettings({ remoteType });
const snapshot = { ...source };
const factory = createFactory(host as never);
const resource = await factory(source);
const nextResource = await factory(source);
const [replicator, nextReplicator] = instances();
expect(instances()).toHaveLength(2);
expect(replicator.host).toBe(host);
expect(replicator.getJoinedDevices).not.toHaveBeenCalled();
expect(replicator.forgetJoinedDevice).not.toHaveBeenCalled();
const devices = {
milestonePresent: true,
locked: false,
cleaned: false,
devices: [{ id: "other", accepted: true }],
};
replicator.getJoinedDevices.mockResolvedValue(devices);
replicator.forgetJoinedDevice.mockResolvedValue("conflict");
source.couchDB_DBNAME = "changed-vault";
source.bucket = "changed-bucket";
await expect(resource.read()).resolves.toBe(devices);
await expect(resource.forget("other")).resolves.toBe("conflict");
expect(replicator.getJoinedDevices).toHaveBeenCalledWith(snapshot);
expect(replicator.getJoinedDevices.mock.calls[0][0]).not.toBe(source);
expect(replicator.forgetJoinedDevice).toHaveBeenCalledWith(snapshot, "other");
const failure = new Error("remote unavailable");
replicator.getJoinedDevices.mockRejectedValue(failure);
await expect(resource.read()).rejects.toBe(failure);
replicator.forgetJoinedDevice.mockRejectedValue(failure);
await expect(resource.forget("other")).rejects.toBe(failure);
const disposal = resource.dispose();
expect(resource.dispose()).toBe(disposal);
await disposal;
expect(replicator.closeReplication).toHaveBeenCalledOnce();
expect(nextReplicator.closeReplication).not.toHaveBeenCalled();
await nextResource.dispose();
expect(nextReplicator.closeReplication).toHaveBeenCalledOnce();
}
);
it("shares one successful asynchronous disposal promise for every probe kind", async () => { it("shares one successful asynchronous disposal promise for every probe kind", async () => {
const couchProbe = await createCouchDBPreferredTweakProbeFactory({} as never)(createSettings()); const couchProbe = await createCouchDBPreferredTweakProbeFactory({} as never)(createSettings());
const objectProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)( const objectProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)(
+5
View File
@@ -14,3 +14,8 @@ export {
type SecuritySeedResourceHost, type SecuritySeedResourceHost,
} from "./securitySeed"; } from "./securitySeed";
export { createCouchDBSynchronisationInformationResourceFactory } from "./synchronisationInformation"; export { createCouchDBSynchronisationInformationResourceFactory } from "./synchronisationInformation";
export {
createCouchDBJoinedDevicesResourceFactory,
createObjectStorageJoinedDevicesResourceFactory,
} from "./joinedDevices";
@@ -0,0 +1,40 @@
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type {
JoinedDevicesResource,
JoinedDevicesResourceFactory,
JoinedDevicesSnapshot,
ForgetJoinedDeviceResult,
} from "@vrtmrz/livesync-commonlib/replication";
import {
LiveSyncCouchDBReplicator,
type LiveSyncCouchDBReplicatorEnv,
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import { createReplicatorDisposer, snapshotRemoteSettings, type ResourceReplicator } from "./shared";
interface JoinedDevicesReplicator extends ResourceReplicator {
getJoinedDevices(setting: RemoteDBSettings): Promise<JoinedDevicesSnapshot>;
forgetJoinedDevice(setting: RemoteDBSettings, deviceId: string): Promise<ForgetJoinedDeviceResult>;
}
function createResource(replicator: JoinedDevicesReplicator, snapshot: RemoteDBSettings): JoinedDevicesResource {
return {
read: () => replicator.getJoinedDevices(snapshot),
forget: (deviceId) => replicator.forgetJoinedDevice(snapshot, deviceId),
dispose: createReplicatorDisposer(replicator),
};
}
export function createCouchDBJoinedDevicesResourceFactory(
host: LiveSyncCouchDBReplicatorEnv
): JoinedDevicesResourceFactory {
return (setting) =>
Promise.resolve(createResource(new LiveSyncCouchDBReplicator(host), snapshotRemoteSettings(setting)));
}
export function createObjectStorageJoinedDevicesResourceFactory(
host: LiveSyncCouchDBReplicatorEnv
): JoinedDevicesResourceFactory {
return (setting) =>
Promise.resolve(createResource(new LiveSyncJournalReplicator(host), snapshotRemoteSettings(setting)));
}
+2 -10
View File
@@ -1,3 +1,4 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
@@ -18,17 +19,7 @@ import {
} from "./turnSettingsPrivacy"; } from "./turnSettingsPrivacy";
class MemorySettingService extends SettingService { class MemorySettingService extends SettingService {
readonly items = new Map<string, string>();
saved?: ObsidianLiveSyncSettings; saved?: ObsidianLiveSyncSettings;
protected setItem(key: string, value: string) {
this.items.set(key, value);
}
protected getItem(key: string) {
return this.items.get(key) ?? "";
}
protected deleteItem(key: string) {
this.items.delete(key);
}
protected saveData(settings: ObsidianLiveSyncSettings) { protected saveData(settings: ObsidianLiveSyncSettings) {
this.saved = structuredClone(settings); this.saved = structuredClone(settings);
return Promise.resolve(); return Promise.resolve();
@@ -70,6 +61,7 @@ describe("managed TURN settings privacy", () => {
const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current }); const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current });
current.remoteConfigurations.managed.uri = originalURI; current.remoteConfigurations.managed.uri = originalURI;
const service = new MemorySettingService(new ServiceContext(), { const service = new MemorySettingService(new ServiceContext(), {
deviceValues: new MemoryValueStore(),
APIService: { APIService: {
getSystemVaultName: () => "test-vault", getSystemVaultName: () => "test-vault",
getAppID: () => "test-app", getAppID: () => "test-app",
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { ValueStoreError } from "@vrtmrz/value-store";
import { AutoAccepting, DEFAULT_SETTINGS, type P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { AutoAccepting, DEFAULT_SETTINGS, type P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { import {
AcceptedStatus, AcceptedStatus,
@@ -33,6 +34,14 @@
let settings = $state<P2PSyncSetting>(initialSettings); let settings = $state<P2PSyncSetting>(initialSettings);
let deviceName = $state<string>(""); let deviceName = $state<string>("");
let deviceNameReady = $state(false);
let dialogStatusReady = $state(false);
type StorageErrorKey = "deviceNameLoad" | "dialogStatusLoad" | "deviceNameWrite" | "dialogStatusWrite";
let storageErrors = $state<Partial<Record<StorageErrorKey, string>>>({});
let mounted = false;
let deviceNameLoadSequence = 0;
let deviceNameWriteQueue: Promise<void> = Promise.resolve();
let dialogStatusWriteQueue: Promise<void> = Promise.resolve();
let eP2PEnabled = $state<boolean>(initialSettings.P2P_Enabled); let eP2PEnabled = $state<boolean>(initialSettings.P2P_Enabled);
let eRelay = $state<string>(initialSettings.P2P_relays); let eRelay = $state<string>(initialSettings.P2P_relays);
@@ -68,6 +77,7 @@
); );
async function saveAndApply() { async function saveAndApply() {
const savedDeviceName = eDeviceName;
// const newSettings = { // const newSettings = {
// ...currentSettings(), // ...currentSettings(),
// P2P_Enabled: eP2PEnabled, // P2P_Enabled: eP2PEnabled,
@@ -92,8 +102,39 @@
}, },
true true
); );
services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, eDeviceName); try {
deviceName = eDeviceName; await queueDeviceNameWrite(savedDeviceName);
if (!mounted) return;
deviceName = savedDeviceName;
clearStorageError("deviceNameWrite");
} catch (error) {
setStorageError("deviceNameWrite", error);
}
}
function setStorageError(key: StorageErrorKey, error: unknown) {
if (mounted) {
storageErrors = {
...storageErrors,
[key]: error instanceof Error ? error.message : String(error),
};
}
}
function clearStorageError(key: StorageErrorKey) {
if (!mounted) return;
const nextErrors = { ...storageErrors };
delete nextErrors[key];
storageErrors = nextErrors;
}
function queueDeviceNameWrite(name: string): Promise<void> {
const write = deviceNameWriteQueue.catch(() => undefined).then(async () => {
if (!mounted) return;
await services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, name);
});
deviceNameWriteQueue = write;
return write;
} }
async function revert() { async function revert() {
eP2PEnabled = settings.P2P_Enabled; eP2PEnabled = settings.P2P_Enabled;
@@ -110,10 +151,7 @@
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined); let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
const applyLoadSettings = (d: P2PSyncSetting, force: boolean) => { const applyLoadSettings = (d: P2PSyncSetting, force: boolean) => {
if (force) { if (force) {
const initDeviceName = void loadDeviceName(true).catch(() => undefined);
services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? services.vault.getVaultName();
deviceName = initDeviceName;
eDeviceName = initDeviceName;
} }
const { P2P_relays, P2P_roomID, P2P_passphrase, P2P_AppID, P2P_AutoAccepting } = d; const { P2P_relays, P2P_roomID, P2P_passphrase, P2P_AppID, P2P_AutoAccepting } = d;
if (force || !isP2PEnabledModified) eP2PEnabled = d.P2P_Enabled; if (force || !isP2PEnabledModified) eP2PEnabled = d.P2P_Enabled;
@@ -129,6 +167,7 @@
settings = d; settings = d;
}; };
onMount(() => { onMount(() => {
mounted = true;
const r = events.onEvent("setting-saved", async (d) => { const r = events.onEvent("setting-saved", async (d) => {
applyLoadSettings(d, false); applyLoadSettings(d, false);
closeServer(); closeServer();
@@ -143,15 +182,52 @@
const r3 = events.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => { const r3 = events.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
replicatorInfo = status; replicatorInfo = status;
}); });
applyLoadSettings(currentSettings(), true); applyLoadSettings(currentSettings(), false);
void loadInitialPreferences().catch(() => undefined);
host.p2p.diagnostics.requestStatus(); host.p2p.diagnostics.requestStatus();
return () => { return () => {
mounted = false;
r(); r();
rx(); rx();
r2(); r2();
r3(); r3();
}; };
}); });
async function loadInitialPreferences() {
const deviceNameLoad = loadDeviceName(true).catch(() => undefined);
const dialogStatusLoad = readDialogStatus().then(
(dialogStatus) => {
if (!mounted) return;
isNoticeOpened = dialogStatus.notice ?? true;
isSettingOpened = dialogStatus.setting ?? true;
dialogStatusReady = true;
clearStorageError("dialogStatusLoad");
},
(error: unknown) => {
setStorageError("dialogStatusLoad", error);
}
);
await Promise.all([deviceNameLoad, dialogStatusLoad]);
}
async function loadDeviceName(force: boolean) {
const request = ++deviceNameLoadSequence;
try {
const savedDeviceName = await services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME, "");
if (!mounted || request !== deviceNameLoadSequence) return;
const initialDeviceName = savedDeviceName;
if (force || !isDeviceNameModified) {
deviceName = initialDeviceName;
eDeviceName = initialDeviceName;
}
deviceNameReady = true;
clearStorageError("deviceNameLoad");
} catch (error) {
if (mounted && request === deviceNameLoadSequence) setStorageError("deviceNameLoad", error);
throw error;
}
}
let isConnected = $derived.by(() => { let isConnected = $derived.by(() => {
return serverInfo?.isConnected ?? false; return serverInfo?.isConnected ?? false;
}); });
@@ -234,26 +310,42 @@
} }
const initialDialogStatusKey = `p2p-dialog-status`; const initialDialogStatusKey = `p2p-dialog-status`;
const getDialogStatus = () => { const readDialogStatus = async (): Promise<{ notice?: boolean; setting?: boolean }> => {
try { try {
const initialDialogStatus = JSON.parse(services.config.getSmallConfig(initialDialogStatusKey) ?? "{}") as { const initialDialogStatus = await services.config.getObject(initialDialogStatusKey);
notice?: boolean; if (typeof initialDialogStatus !== "object" || initialDialogStatus === null || Array.isArray(initialDialogStatus)) {
setting?: boolean; return {};
}
const status = initialDialogStatus as Record<string, unknown>;
return {
...(typeof status.notice === "boolean" ? { notice: status.notice } : {}),
...(typeof status.setting === "boolean" ? { setting: status.setting } : {}),
}; };
return initialDialogStatus; } catch (error) {
} catch { if (error instanceof ValueStoreError && error.code === "invalid-data") {
return {}; // Invalid optional UI state is recovered with the historical defaults.
return {};
}
throw error;
} }
}; };
const initialDialogStatus = getDialogStatus(); let isNoticeOpened = $state<boolean>(true);
let isNoticeOpened = $state<boolean>(initialDialogStatus.notice ?? true); let isSettingOpened = $state<boolean>(true);
let isSettingOpened = $state<boolean>(initialDialogStatus.setting ?? true);
$effect(() => { $effect(() => {
if (!mounted || !dialogStatusReady) return;
const dialogStatus = { const dialogStatus = {
notice: isNoticeOpened, notice: isNoticeOpened,
setting: isSettingOpened, setting: isSettingOpened,
}; };
services.config.setSmallConfig(initialDialogStatusKey, JSON.stringify(dialogStatus)); const write = dialogStatusWriteQueue.catch(() => undefined).then(async () => {
if (!mounted) return;
await services.config.setObject(initialDialogStatusKey, dialogStatus);
});
dialogStatusWriteQueue = write;
void write.then(
() => clearStorageError("dialogStatusWrite"),
(error: unknown) => setStorageError("dialogStatusWrite", error)
);
}); });
let isObsidian = $derived.by(() => { let isObsidian = $derived.by(() => {
return services.API.getPlatform() === "obsidian"; return services.API.getPlatform() === "obsidian";
@@ -261,6 +353,9 @@
</script> </script>
<article> <article>
{#each Object.values(storageErrors) as storageError}
<p role="alert">{storageError}</p>
{/each}
<h1>{_msg("Peer to Peer Replicator")}</h1> <h1>{_msg("Peer to Peer Replicator")}</h1>
<details bind:open={isNoticeOpened}> <details bind:open={isNoticeOpened}>
<summary>{_msg("P2P.Note.Summary")}</summary> <summary>{_msg("P2P.Note.Summary")}</summary>
@@ -345,6 +440,7 @@
type="text" type="text"
placeholder="iphone-16" placeholder="iphone-16"
bind:value={eDeviceName} bind:value={eDeviceName}
disabled={!deviceNameReady}
autocomplete="off" autocomplete="off"
/> />
</label> </label>
@@ -383,7 +479,7 @@
</tr> --> </tr> -->
</tbody> </tbody>
</table> </table>
<button disabled={!isAnyModified} class="button mod-cta" onclick={saveAndApply} <button disabled={!isAnyModified || !deviceNameReady} class="button mod-cta" onclick={saveAndApply}
>{_msg("Save and Apply")}</button >{_msg("Save and Apply")}</button
> >
<button disabled={!isAnyModified} class="button" onclick={revert}>{_msg("Revert changes")}</button> <button disabled={!isAnyModified} class="button" onclick={revert}>{_msg("Revert changes")}</button>
@@ -1,4 +1,5 @@
import { ItemView, Notice, Setting, type WorkspaceLeaf } from "@/deps"; import { ItemView, Notice, Setting, type WorkspaceLeaf } from "@/deps";
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import { import {
REVIEW_HARNESS_SCENARIOS, REVIEW_HARNESS_SCENARIOS,
type ReviewHarnessScenarioId, type ReviewHarnessScenarioId,
@@ -101,7 +102,7 @@ export class ReviewHarnessView extends ItemView {
true true
); );
this.addActionButton(actions, "Restart and return", "review-harness-restart", () => this.addActionButton(actions, "Restart and return", "review-harness-restart", () =>
this.controller.prepareCompatibilityReviewRestart() this.controller.prepareCompatibilityReviewRestart().catch((error) => Logger(error, LOG_LEVEL_NOTICE))
); );
} }
} }
@@ -23,9 +23,9 @@ export interface ReviewHarnessRuntime {
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>; runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
runIdBenchmark(): Promise<ReviewHarnessScenarioResult>; runIdBenchmark(): Promise<ReviewHarnessScenarioResult>;
runFileContentHashBenchmark(): Promise<ReviewHarnessScenarioResult>; runFileContentHashBenchmark(): Promise<ReviewHarnessScenarioResult>;
readContinuation(): string | null; readContinuation(): Promise<string | null>;
writeContinuation(value: string): void; writeContinuation(value: string): Promise<void>;
deleteContinuation(): void; deleteContinuation(): Promise<void>;
restart(): void; restart(): void;
reportError(error: unknown): void; reportError(error: unknown): void;
copyText(value: string): Promise<void>; copyText(value: string): Promise<void>;
@@ -68,6 +68,7 @@ export class ReviewHarnessController {
private current: ReviewHarnessScenarioId | null = null; private current: ReviewHarnessScenarioId | null = null;
private resumedRequestId: string | null = null; private resumedRequestId: string | null = null;
private continuationError: string | null = null; private continuationError: string | null = null;
private continuationConsumption: Promise<void> | undefined;
constructor(private readonly runtime: ReviewHarnessRuntime) {} constructor(private readonly runtime: ReviewHarnessRuntime) {}
@@ -102,10 +103,20 @@ export class ReviewHarnessController {
}; };
} }
consumeContinuation(): void { consumeContinuation(): Promise<void> {
const serialised = this.runtime.readContinuation(); if (this.continuationConsumption) return this.continuationConsumption;
const consumption = this.consumeContinuationFromStore();
const trackedConsumption = consumption.finally(() => {
if (this.continuationConsumption === trackedConsumption) this.continuationConsumption = undefined;
});
this.continuationConsumption = trackedConsumption;
return trackedConsumption;
}
private async consumeContinuationFromStore(): Promise<void> {
const serialised = await this.runtime.readContinuation();
if (!serialised) return; if (!serialised) return;
this.runtime.deleteContinuation(); await this.runtime.deleteContinuation();
const parsed = parsePendingReviewRun(serialised); const parsed = parsePendingReviewRun(serialised);
if (!parsed.pendingRun) { if (!parsed.pendingRun) {
this.continuationError = parsed.error ?? "The Review Harness continuation was invalid."; this.continuationError = parsed.error ?? "The Review Harness continuation was invalid.";
@@ -232,7 +243,7 @@ export class ReviewHarnessController {
this.record("scenario-failed", id); this.record("scenario-failed", id);
} }
prepareCompatibilityReviewRestart(): void { async prepareCompatibilityReviewRestart(): Promise<void> {
const requestedAt = this.runtime.now().toISOString(); const requestedAt = this.runtime.now().toISOString();
const pending = { const pending = {
formatVersion: 1, formatVersion: 1,
@@ -241,7 +252,7 @@ export class ReviewHarnessController {
stage: "awaiting-restart", stage: "awaiting-restart",
requestedAt, requestedAt,
} as const; } as const;
this.runtime.writeContinuation(JSON.stringify(pending)); await this.runtime.writeContinuation(JSON.stringify(pending));
this.results["compatibility-review"] = { this.results["compatibility-review"] = {
status: "waiting-for-user", status: "waiting-for-user",
detail: "Restart requested. The one-shot continuation will be removed before the review resumes.", detail: "Restart requested. The one-shot continuation will be removed before the review resumes.",
@@ -90,14 +90,14 @@ function createRuntime(): ReviewHarnessRuntime & {
detail: "File content hash measurements completed.", detail: "File content hash measurements completed.",
observations: ["SHA-256, 4 KiB: median=1.00 ms; range=1.00–1.00 ms"], observations: ["SHA-256, 4 KiB: median=1.00 ms; range=1.00–1.00 ms"],
})), })),
readContinuation() { async readContinuation() {
return this.continuation; return this.continuation;
}, },
writeContinuation(value) { async writeContinuation(value) {
this.events.push("write"); this.events.push("write");
this.continuation = value; this.continuation = value;
}, },
deleteContinuation() { async deleteContinuation() {
this.events.push("delete"); this.events.push("delete");
this.continuation = null; this.continuation = null;
}, },
@@ -251,7 +251,7 @@ describe("ReviewHarnessController", () => {
expect(controller.snapshot().running).toBe(false); expect(controller.snapshot().running).toBe(false);
}); });
it("deletes a one-shot continuation before exposing the resumed guided step", () => { it("deletes a one-shot continuation before exposing the resumed guided step", async () => {
const runtime = createRuntime(); const runtime = createRuntime();
runtime.continuation = JSON.stringify({ runtime.continuation = JSON.stringify({
formatVersion: 1, formatVersion: 1,
@@ -262,7 +262,7 @@ describe("ReviewHarnessController", () => {
}); });
const controller = new ReviewHarnessController(runtime); const controller = new ReviewHarnessController(runtime);
controller.consumeContinuation(); await controller.consumeContinuation();
expect(runtime.events).toEqual(["delete"]); expect(runtime.events).toEqual(["delete"]);
expect(controller.snapshot().results["compatibility-review"]).toMatchObject({ expect(controller.snapshot().results["compatibility-review"]).toMatchObject({
@@ -271,7 +271,7 @@ describe("ReviewHarnessController", () => {
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z"); expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
}); });
it("does not copy rejected continuation values into the report", () => { it("does not copy rejected continuation values into the report", async () => {
const runtime = createRuntime(); const runtime = createRuntime();
runtime.continuation = JSON.stringify({ runtime.continuation = JSON.stringify({
formatVersion: 1, formatVersion: 1,
@@ -282,7 +282,7 @@ describe("ReviewHarnessController", () => {
}); });
const controller = new ReviewHarnessController(runtime); const controller = new ReviewHarnessController(runtime);
controller.consumeContinuation(); await controller.consumeContinuation();
expect(controller.snapshot().continuationError).toContain("private-vault"); expect(controller.snapshot().continuationError).toContain("private-vault");
expect(controller.snapshot().results["compatibility-review"].detail).toBe( expect(controller.snapshot().results["compatibility-review"].detail).toBe(
@@ -292,7 +292,7 @@ describe("ReviewHarnessController", () => {
expect(controller.createReport()).not.toContain("secret-note.md"); expect(controller.createReport()).not.toContain("secret-note.md");
}); });
it("does not accept an injected request ID which could enter the report transcript", () => { it("does not accept an injected request ID which could enter the report transcript", async () => {
const runtime = createRuntime(); const runtime = createRuntime();
runtime.continuation = JSON.stringify({ runtime.continuation = JSON.stringify({
formatVersion: 1, formatVersion: 1,
@@ -303,18 +303,18 @@ describe("ReviewHarnessController", () => {
}); });
const controller = new ReviewHarnessController(runtime); const controller = new ReviewHarnessController(runtime);
controller.consumeContinuation(); await controller.consumeContinuation();
expect(controller.snapshot().results["compatibility-review"].status).toBe("failed"); expect(controller.snapshot().results["compatibility-review"].status).toBe("failed");
expect(controller.createReport()).not.toContain("private-vault"); expect(controller.createReport()).not.toContain("private-vault");
expect(controller.createReport()).not.toContain("secret-note.md"); expect(controller.createReport()).not.toContain("secret-note.md");
}); });
it("persists the fixed continuation before requesting restart", () => { it("persists the fixed continuation before requesting restart", async () => {
const runtime = createRuntime(); const runtime = createRuntime();
const controller = new ReviewHarnessController(runtime); const controller = new ReviewHarnessController(runtime);
controller.prepareCompatibilityReviewRestart(); await controller.prepareCompatibilityReviewRestart();
expect(runtime.events).toEqual(["write", "restart"]); expect(runtime.events).toEqual(["write", "restart"]);
expect(JSON.parse(runtime.continuation!)).toMatchObject({ expect(JSON.parse(runtime.continuation!)).toMatchObject({
@@ -324,6 +324,135 @@ describe("ReviewHarnessController", () => {
}); });
}); });
it("waits for the continuation save before requesting restart", async () => {
const runtime = createRuntime();
let markSaveStarted!: () => void;
const saveStarted = new Promise<void>((resolve) => {
markSaveStarted = resolve;
});
let finishSave!: () => void;
const saving = new Promise<void>((resolve) => {
finishSave = resolve;
});
runtime.writeContinuation = vi.fn(async (value) => {
runtime.events.push("write-started");
markSaveStarted();
await saving;
runtime.continuation = value;
runtime.events.push("write-saved");
});
const controller = new ReviewHarnessController(runtime);
const preparing = controller.prepareCompatibilityReviewRestart();
await saveStarted;
expect(runtime.events).toEqual(["write-started"]);
expect(runtime.restart).not.toHaveBeenCalled();
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
finishSave();
await preparing;
expect(runtime.events).toEqual(["write-started", "write-saved", "restart"]);
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
});
it("keeps the guided review retryable when saving its continuation fails", async () => {
const runtime = createRuntime();
const controller = new ReviewHarnessController(runtime);
await controller.runScenario("compatibility-review");
runtime.writeContinuation = vi.fn(async () => {
runtime.events.push("write-failed");
throw new Error("continuation save failed");
});
await expect(controller.prepareCompatibilityReviewRestart()).rejects.toThrow("continuation save failed");
expect(runtime.events).toEqual(["write-failed"]);
expect(runtime.restart).not.toHaveBeenCalled();
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
});
it("waits for one continuation deletion before exposing it and coalesces concurrent consumption", async () => {
const runtime = createRuntime();
runtime.continuation = JSON.stringify({
formatVersion: 1,
requestId: "compatibility-review-2026-07-18T11:59:00.000Z",
scenarioId: "compatibility-review",
stage: "awaiting-restart",
requestedAt: "2026-07-18T11:59:00.000Z",
});
let markDeleteStarted!: () => void;
const deleteStarted = new Promise<void>((resolve) => {
markDeleteStarted = resolve;
});
let finishDelete!: () => void;
const deleting = new Promise<void>((resolve) => {
finishDelete = resolve;
});
runtime.readContinuation = vi.fn(async () => {
runtime.events.push("read");
return runtime.continuation;
});
runtime.deleteContinuation = vi.fn(async () => {
runtime.events.push("delete-started");
markDeleteStarted();
await deleting;
runtime.continuation = null;
runtime.events.push("delete-finished");
});
const controller = new ReviewHarnessController(runtime);
const firstConsumption = controller.consumeContinuation();
const duplicateConsumption = controller.consumeContinuation();
await deleteStarted;
expect(duplicateConsumption).toBe(firstConsumption);
expect(runtime.events).toEqual(["read", "delete-started"]);
expect(controller.snapshot().resumedRequestId).toBeNull();
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
finishDelete();
await firstConsumption;
expect(runtime.events).toEqual(["read", "delete-started", "delete-finished"]);
expect(runtime.readContinuation).toHaveBeenCalledOnce();
expect(runtime.deleteContinuation).toHaveBeenCalledOnce();
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
});
it("keeps a failed continuation deletion available for retry", async () => {
const runtime = createRuntime();
runtime.continuation = JSON.stringify({
formatVersion: 1,
requestId: "compatibility-review-2026-07-18T11:59:00.000Z",
scenarioId: "compatibility-review",
stage: "awaiting-restart",
requestedAt: "2026-07-18T11:59:00.000Z",
});
runtime.deleteContinuation = vi
.fn<ReviewHarnessRuntime["deleteContinuation"]>()
.mockRejectedValueOnce(new Error("continuation delete failed"))
.mockImplementation(async () => {
runtime.events.push("delete");
runtime.continuation = null;
});
const controller = new ReviewHarnessController(runtime);
await expect(controller.consumeContinuation()).rejects.toThrow("continuation delete failed");
expect(runtime.continuation).not.toBeNull();
expect(controller.snapshot().resumedRequestId).toBeNull();
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
await controller.consumeContinuation();
expect(runtime.continuation).toBeNull();
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
});
it("passes only after the actual compatibility review clears its pause", async () => { it("passes only after the actual compatibility review clears its pause", async () => {
const runtime = createRuntime(); const runtime = createRuntime();
const controller = new ReviewHarnessController(runtime); const controller = new ReviewHarnessController(runtime);
@@ -8,13 +8,13 @@ import { openObsidianSettings } from "@/common/obsidianSettings.ts";
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule { export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
settingTab!: ObsidianLiveSyncSettingTab; settingTab!: ObsidianLiveSyncSettingTab;
_everyOnloadAfterLoadSettings(): Promise<boolean> { async _everyOnloadAfterLoadSettings(): Promise<boolean> {
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin); this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
this.settingTab.reloadAllSettings(true); await this.settingTab.loadSettings();
this.plugin.addSettingTab(this.settingTab); this.plugin.addSettingTab(this.settingTab);
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting()); eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting());
return Promise.resolve(true); return true;
} }
openSetting() { openSetting() {
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const settingTabState = vi.hoisted(() => ({ const settingTabState = vi.hoisted(() => ({
callOrder: [] as string[], callOrder: [] as string[],
reloadAllSettings: vi.fn<(skipUpdate?: boolean) => void>(), loadSettings: vi.fn<() => Promise<void>>(),
})); }));
const eventHubState = vi.hoisted(() => ({ const eventHubState = vi.hoisted(() => ({
@@ -11,9 +11,9 @@ const eventHubState = vi.hoisted(() => ({
vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({ vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({
ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab { ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab {
reloadAllSettings(skipUpdate?: boolean) { async loadSettings() {
settingTabState.callOrder.push(`reload:${String(skipUpdate)}`); await settingTabState.loadSettings();
settingTabState.reloadAllSettings(skipUpdate); settingTabState.callOrder.push("settings-loaded");
} }
}, },
})); }));
@@ -65,7 +65,7 @@ function createModuleHarness() {
describe("ModuleObsidianSettingDialogue startup lifecycle", () => { describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
beforeEach(() => { beforeEach(() => {
settingTabState.callOrder.length = 0; settingTabState.callOrder.length = 0;
settingTabState.reloadAllSettings.mockClear(); settingTabState.loadSettings.mockReset().mockResolvedValue(undefined);
eventHubState.onEvent.mockClear(); eventHubState.onEvent.mockClear();
}); });
@@ -79,13 +79,29 @@ describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
}); });
it("seeds the setting editor without requesting a render before registration", async () => { it("seeds the setting editor without requesting a render before registration", async () => {
const { initialisationHandler, settingsLoadedHandler } = createModuleHarness(); const { initialisationHandler, settingsLoadedHandler, plugin } = createModuleHarness();
let finishRead!: () => void;
settingTabState.loadSettings.mockReturnValueOnce(
new Promise<void>((resolve) => {
finishRead = resolve;
})
);
const handler = settingsLoadedHandler() ?? initialisationHandler(); const handler = settingsLoadedHandler() ?? initialisationHandler();
expect(handler).toBeTypeOf("function"); expect(handler).toBeTypeOf("function");
await handler!(); const loading = handler!();
expect(plugin.addSettingTab).not.toHaveBeenCalled();
finishRead();
await loading;
expect(settingTabState.reloadAllSettings).toHaveBeenCalledWith(true); expect(settingTabState.loadSettings).toHaveBeenCalledOnce();
expect(settingTabState.callOrder).toEqual(["reload:true", "add-setting-tab"]); expect(settingTabState.callOrder).toEqual(["settings-loaded", "add-setting-tab"]);
});
it("does not register an editor when reading device values fails", async () => {
const { settingsLoadedHandler, plugin } = createModuleHarness();
const failure = new Error("device storage unavailable");
settingTabState.loadSettings.mockRejectedValueOnce(failure);
await expect(settingsLoadedHandler()!()).rejects.toBe(failure);
expect(plugin.addSettingTab).not.toHaveBeenCalled();
}); });
}); });
@@ -1,3 +1,4 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian"; import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
@@ -158,6 +159,7 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
}, },
services: { services: {
setting: { setting: {
deviceValues: new MemoryValueStore(),
getDeviceAndVaultName: vi.fn(() => ""), getDeviceAndVaultName: vi.fn(() => ""),
saveSettingData: vi.fn(async () => undefined), saveSettingData: vi.fn(async () => undefined),
}, },
@@ -457,7 +459,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
expect(runtime.pageCleanup).toHaveBeenCalledOnce(); expect(runtime.pageCleanup).toHaveBeenCalledOnce();
}); });
it("rebuilds the catalogue when an externally loaded setting changes page visibility", () => { it("rebuilds the catalogue when an externally loaded setting changes page visibility", async () => {
const tab = createSettingsTab(); const tab = createSettingsTab();
const changeLog = findPage(tab, "Change Log"); const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) { if (!changeLog.page) {
@@ -466,7 +468,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
changeLog.page().display(); changeLog.page().display();
tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode; tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode;
tab.requestReload(); await tab.requestReload();
expect(tab.update).toHaveBeenCalledOnce(); expect(tab.update).toHaveBeenCalledOnce();
}); });
@@ -480,7 +482,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
expect(tab.update).toHaveBeenCalledOnce(); expect(tab.update).toHaveBeenCalledOnce();
}); });
it("rebuilds translated catalogue names when the display language changes externally", () => { it("rebuilds translated catalogue names when the display language changes externally", async () => {
const tab = createSettingsTab(); const tab = createSettingsTab();
const changeLog = findPage(tab, "Change Log"); const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) { if (!changeLog.page) {
@@ -489,12 +491,12 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
changeLog.page().display(); changeLog.page().display();
tab.core.settings.displayLanguage = "ja"; tab.core.settings.displayLanguage = "ja";
tab.requestReload(); await tab.requestReload();
expect(tab.update).toHaveBeenCalledOnce(); expect(tab.update).toHaveBeenCalledOnce();
}); });
it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", () => { it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", async () => {
const tab = createSettingsTab(); const tab = createSettingsTab();
const changeLog = findPage(tab, "Change Log"); const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) { if (!changeLog.page) {
@@ -505,7 +507,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
tab.editingSettings.usePowerUserMode = true; tab.editingSettings.usePowerUserMode = true;
tab.core.settings.usePowerUserMode = true; tab.core.settings.usePowerUserMode = true;
tab.requestReload(); await tab.requestReload();
const configureAnchor = vi.mocked(tab.core.confirm.askInPopup).mock.calls[0]?.[2]; const configureAnchor = vi.mocked(tab.core.confirm.askInPopup).mock.calls[0]?.[2];
expect(configureAnchor).toBeTypeOf("function"); expect(configureAnchor).toBeTypeOf("function");
let acceptExternalSetting: (() => void) | undefined; let acceptExternalSetting: (() => void) | undefined;
@@ -52,7 +52,6 @@ import {
type PageFunctions, type PageFunctions,
type UpdateFunction, type UpdateFunction,
} from "./SettingPane.ts"; } from "./SettingPane.ts";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore"; import { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replication/journal/objectstore/MinioStorageAdapter"; import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replication/journal/objectstore/MinioStorageAdapter";
import { closeObsidianSettings } from "@/common/obsidianSettings.ts"; import { closeObsidianSettings } from "@/common/obsidianSettings.ts";
@@ -100,6 +99,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
} }
selectedScreen = ""; selectedScreen = "";
private localConfigurationPassphrase = "";
private localSettingsReadVersion = 0;
_editingSettings?: AllSettings; _editingSettings?: AllSettings;
// Buffered Settings for editing // Buffered Settings for editing
get editingSettings(): AllSettings { get editingSettings(): AllSettings {
@@ -162,8 +164,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) { async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) {
if (key == "deviceAndVaultName") { if (key == "deviceAndVaultName") {
this.services.setting.setDeviceAndVaultName(this.editingSettings?.[key] ?? ""); this.services.setting.setDeviceAndVaultName(this.editingSettings?.[key] ?? "");
this.services.setting.saveDeviceAndVaultName(); await this.services.setting.saveDeviceAndVaultName();
return await Promise.resolve();
} }
} }
/** /**
@@ -225,6 +226,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
appliedConfigurationEncryption.configPassphrase appliedConfigurationEncryption.configPassphrase
); );
if (appliedConfigurationEncryption.configPassphraseStore === "LOCALSTORAGE") {
this.localSettingsReadVersion++;
this.localConfigurationPassphrase = appliedConfigurationEncryption.configPassphrase;
}
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphraseStore"); this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphraseStore");
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphrase"); this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphrase");
} }
@@ -285,9 +290,21 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
} }
} }
private async readLocalSettings(): Promise<boolean> {
const version = ++this.localSettingsReadVersion;
const passphrase = await this.services.setting.deviceValues.getValue("ls-setting-passphrase", "");
if (version !== this.localSettingsReadVersion) return false;
this.localConfigurationPassphrase = passphrase;
return true;
}
async loadSettings(): Promise<void> {
if (await this.readLocalSettings()) this.reloadAllSettings(true);
}
reloadAllLocalSettings() { reloadAllLocalSettings() {
const ret = { ...OnDialogSettingsDefault }; const ret = { ...OnDialogSettingsDefault };
ret.configPassphrase = compatGlobal.localStorage.getItem("ls-setting-passphrase") || ""; ret.configPassphrase = this.localConfigurationPassphrase;
ret.preset = ""; ret.preset = "";
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName(); ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
return ret; return ret;
@@ -402,7 +419,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
this.plugin = plugin; this.plugin = plugin;
Setting.env = this; Setting.env = this;
eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => { eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => {
this.requestReload(); void this.requestReload().catch((error: unknown) => Logger(error, LOG_LEVEL_NOTICE));
}); });
this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh()); this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh());
this.addOnSaved("showStatusOnEditor", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR)); this.addOnSaved("showStatusOnEditor", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR));
@@ -545,7 +562,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
); );
} }
requestReload() { async requestReload(): Promise<void> {
if (!(await this.readLocalSettings())) return;
const nativeTabIsShown = const nativeTabIsShown =
this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown(); this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown();
if (this.isShown || nativeTabIsShown) { if (this.isShown || nativeTabIsShown) {
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication"; import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; import { MemoryValueStore } from "@vrtmrz/value-store";
const settingsInitialisationMocks = vi.hoisted(() => ({ const settingsInitialisationMocks = vi.hoisted(() => ({
applySettingsWithInitialisationChoice: vi.fn(), applySettingsWithInitialisationChoice: vi.fn(),
@@ -76,8 +76,6 @@ beforeEach(() => {
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset(); settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset();
loggerMocks.Logger.mockClear(); loggerMocks.Logger.mockClear();
vi.mocked($msg).mockClear(); vi.mocked($msg).mockClear();
vi.mocked(compatGlobal.localStorage.getItem).mockReset().mockReturnValue(null);
vi.mocked(compatGlobal.localStorage.setItem).mockReset();
}); });
describe("ObsidianLiveSyncSettingTab passphrase verification", () => { describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
@@ -280,6 +278,7 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
}, },
services: { services: {
setting: { setting: {
deviceValues: new MemoryValueStore(),
saveSettingData, saveSettingData,
getDeviceAndVaultName: vi.fn(() => ""), getDeviceAndVaultName: vi.fn(() => ""),
suspendAllSync, suspendAllSync,
@@ -665,6 +664,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
}, },
services: { services: {
setting: { setting: {
deviceValues: new MemoryValueStore(),
saveSettingData, saveSettingData,
getDeviceAndVaultName: vi.fn(() => ""), getDeviceAndVaultName: vi.fn(() => ""),
}, },
@@ -692,6 +692,54 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
return { tab, saveSettingData }; return { tab, saveSettingData };
} }
it("awaits the stored passphrase before initialising the editing buffer", async () => {
const { tab } = createSettingsTab();
tab.resetEditingSettings();
let resolveRead!: (value: string) => void;
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
new Promise<string>((resolve) => {
resolveRead = resolve;
})
);
const loading = tab.loadSettings();
expect(tab.initialSettings).toBeUndefined();
resolveRead("stored passphrase");
await loading;
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
});
it("preserves the loaded settings when device storage cannot be read", async () => {
const { tab } = createSettingsTab();
await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "stored passphrase");
await tab.loadSettings();
const failure = new Error("device storage unavailable");
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockRejectedValueOnce(failure);
await expect(tab.requestReload()).rejects.toBe(failure);
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
});
it("does not let a delayed read overwrite a successfully saved passphrase", async () => {
const { tab } = createSettingsTab();
await tab.loadSettings();
let resolveRead!: (value: string) => void;
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
new Promise<string>((resolve) => {
resolveRead = resolve;
})
);
const loading = tab.loadSettings();
Object.assign(tab.services.setting, { changeConfigurationEncryption: vi.fn(async () => undefined) });
tab.editingSettings.configPassphraseStore = "LOCALSTORAGE";
tab.editingSettings.configPassphrase = "new passphrase";
await tab.saveSettings(["configPassphraseStore", "configPassphrase"]);
resolveRead("old passphrase");
await loading;
tab.reloadAllSettings();
expect(tab.initialSettings!.configPassphrase).toBe("new passphrase");
});
it("loads the imperative fallback without a SettingPage runtime export", () => { it("loads the imperative fallback without a SettingPage runtime export", () => {
const { tab } = createSettingsTab(); const { tab } = createSettingsTab();
@@ -779,7 +827,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
it("saves a custom configuration passphrase edit through the setting service", async () => { it("saves a custom configuration passphrase edit through the setting service", async () => {
const { tab } = createSettingsTab(); const { tab } = createSettingsTab();
const changeConfigurationEncryption = vi.fn(async () => { const changeConfigurationEncryption = vi.fn(async () => {
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("new custom passphrase"); await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "new custom passphrase");
}); });
Object.assign(tab.core.services.setting, { changeConfigurationEncryption }); Object.assign(tab.core.services.setting, { changeConfigurationEncryption });
const store = "LOCALSTORAGE"; const store = "LOCALSTORAGE";
@@ -787,13 +835,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
tab.initialSettings!.configPassphraseStore = store; tab.initialSettings!.configPassphraseStore = store;
tab.editingSettings.configPassphrase = "new custom passphrase"; tab.editingSettings.configPassphrase = "new custom passphrase";
tab.initialSettings!.configPassphrase = "old custom passphrase"; tab.initialSettings!.configPassphrase = "old custom passphrase";
vi.mocked(compatGlobal.localStorage.setItem).mockClear(); const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
await tab.saveSettings(["configPassphrase"]); await tab.saveSettings(["configPassphrase"]);
expect(changeConfigurationEncryption).toHaveBeenCalledOnce(); expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
expect(changeConfigurationEncryption).toHaveBeenCalledWith(store, "new custom passphrase"); expect(changeConfigurationEncryption).toHaveBeenCalledWith(store, "new custom passphrase");
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled(); expect(setValue).toHaveBeenCalledOnce();
expect(tab.initialSettings!.configPassphrase).toBe("new custom passphrase"); expect(tab.initialSettings!.configPassphrase).toBe("new custom passphrase");
}); });
@@ -811,7 +859,6 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
tab.initialSettings!.configPassphraseStore = "LOCALSTORAGE"; tab.initialSettings!.configPassphraseStore = "LOCALSTORAGE";
tab.initialSettings!.configPassphrase = "previous passphrase"; tab.initialSettings!.configPassphrase = "previous passphrase";
tab.editingSettings.configPassphrase = "passphrase A"; tab.editingSettings.configPassphrase = "passphrase A";
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("passphrase A");
const apply = tab.saveSettings(["configPassphrase"]); const apply = tab.saveSettings(["configPassphrase"]);
expect(changeConfigurationEncryption).toHaveBeenCalledWith("LOCALSTORAGE", "passphrase A"); expect(changeConfigurationEncryption).toHaveBeenCalledWith("LOCALSTORAGE", "passphrase A");
@@ -840,13 +887,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
tab.editingSettings.configPassphraseStore = editedStore; tab.editingSettings.configPassphraseStore = editedStore;
tab.initialSettings!.configPassphrase = "old custom passphrase"; tab.initialSettings!.configPassphrase = "old custom passphrase";
tab.editingSettings.configPassphrase = "new custom passphrase"; tab.editingSettings.configPassphrase = "new custom passphrase";
vi.mocked(compatGlobal.localStorage.setItem).mockClear(); const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
await expect(tab.saveSettings(["configPassphraseStore", "configPassphrase"])).rejects.toBe(failure); await expect(tab.saveSettings(["configPassphraseStore", "configPassphrase"])).rejects.toBe(failure);
expect(changeConfigurationEncryption).toHaveBeenCalledOnce(); expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
expect(changeConfigurationEncryption).toHaveBeenCalledWith(editedStore, "new custom passphrase"); expect(changeConfigurationEncryption).toHaveBeenCalledWith(editedStore, "new custom passphrase");
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled(); expect(setValue).not.toHaveBeenCalled();
expect(tab.core.settings.configPassphraseStore).toBe(savedStore); expect(tab.core.settings.configPassphraseStore).toBe(savedStore);
expect(tab.initialSettings!.configPassphraseStore).toBe(savedStore); expect(tab.initialSettings!.configPassphraseStore).toBe(savedStore);
expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase"); expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase");
@@ -1,3 +1,4 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
@@ -37,11 +38,6 @@ import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/
import { SetupManager, UserMode } from "./SetupManager"; import { SetupManager, UserMode } from "./SetupManager";
class TestSettingService extends SettingService<ServiceContext> { class TestSettingService extends SettingService<ServiceContext> {
protected setItem(_key: string, _value: string): void {}
protected getItem(_key: string): string {
return "";
}
protected deleteItem(_key: string): void {}
protected saveData(_setting: ObsidianLiveSyncSettings): Promise<void> { protected saveData(_setting: ObsidianLiveSyncSettings): Promise<void> {
return Promise.resolve(); return Promise.resolve();
} }
@@ -65,6 +61,7 @@ function createLegacyRemoteSetting(): ObsidianLiveSyncSettings {
function createSetupManager() { function createSetupManager() {
const setting = new TestSettingService(new ServiceContext(), { const setting = new TestSettingService(new ServiceContext(), {
deviceValues: new MemoryValueStore(),
APIService: { APIService: {
getSystemVaultName: vi.fn(() => "vault"), getSystemVaultName: vi.fn(() => "vault"),
getAppID: vi.fn(() => "app"), getAppID: vi.fn(() => "app"),
@@ -52,26 +52,48 @@
const context = getDialogContext(); const context = getDialogContext();
let error = $state(""); let error = $state("");
let connectionPathResetNotice = $state(false); let connectionPathResetNotice = $state(false);
let peerNameReady = $state(false);
let mounted = false;
const hasValidTurnServer = $derived(hasP2PTurnConfiguration(syncSetting)); const hasValidTurnServer = $derived(hasP2PTurnConfiguration(syncSetting));
type Props = GuestDialogProps<SetupRemoteP2PResultType, SetupRemoteP2PInitialData>; type Props = GuestDialogProps<SetupRemoteP2PResultType, SetupRemoteP2PInitialData>;
const { setResult, getInitialData }: Props = $props(); const { setResult, getInitialData }: Props = $props();
let connectionProbe: SetupRemoteP2PInitialData["connectionProbe"] | undefined; let connectionProbe: SetupRemoteP2PInitialData["connectionProbe"] | undefined;
onMount(() => { onMount(() => {
mounted = true;
const initialData = getInitialData?.(); const initialData = getInitialData?.();
connectionProbe = initialData?.connectionProbe; connectionProbe = initialData?.connectionProbe;
const initialSettings = initialData?.settings; const initialSettings = initialData?.settings;
if (initialSettings) { if (initialSettings) {
syncSetting = pickP2PSyncSettings(initialSettings); syncSetting = pickP2PSyncSettings(initialSettings);
} }
const initialPeerName = (initialSettings?.P2P_DevicePeerName ?? "").trim(); const savedPeerName = initialSettings?.P2P_DevicePeerName;
const initialPeerName = typeof savedPeerName === "string" ? savedPeerName.trim() : "";
if (initialPeerName !== "") { if (initialPeerName !== "") {
return; peerNameReady = true;
} return () => {
const cachedPeerName = context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME); mounted = false;
if (cachedPeerName) { };
syncSetting.P2P_DevicePeerName = cachedPeerName as string;
} }
void context.services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME).then(
(cachedPeerName) => {
if (mounted) {
if (typeof cachedPeerName === "string" && cachedPeerName) {
syncSetting.P2P_DevicePeerName = cachedPeerName;
}
peerNameReady = true;
}
},
(loadError: unknown) => {
if (mounted) {
error = loadError instanceof Error ? loadError.message : String(loadError);
peerNameReady = true;
}
}
);
return () => {
mounted = false;
};
}); });
function generateSetting() { function generateSetting() {
const connSetting: P2PSyncSetting = { const connSetting: P2PSyncSetting = {
@@ -193,6 +215,7 @@
} }
async function checkAndCommit() { async function checkAndCommit() {
if (!peerNameReady) return;
error = ""; error = "";
try { try {
error = (await checkConnection()) || ""; error = (await checkConnection()) || "";
@@ -207,6 +230,7 @@
} }
} }
function commit() { function commit() {
if (!peerNameReady) return;
error = validateManagedTurnSettings(syncSetting) ?? ""; error = validateManagedTurnSettings(syncSetting) ?? "";
if (error) return; if (error) return;
const setting = pickP2PSyncSettings(generateSetting()); const setting = pickP2PSyncSettings(generateSetting());
@@ -217,6 +241,7 @@
} }
const canProceed = $derived.by(() => { const canProceed = $derived.by(() => {
return ( return (
peerNameReady &&
syncSetting.P2P_relays.trim() !== "" && syncSetting.P2P_relays.trim() !== "" &&
syncSetting.P2P_roomID.trim() !== "" && syncSetting.P2P_roomID.trim() !== "" &&
syncSetting.P2P_passphrase.trim() !== "" && syncSetting.P2P_passphrase.trim() !== "" &&
@@ -291,6 +316,7 @@
autocorrect="off" autocorrect="off"
autocapitalize="off" autocapitalize="off"
spellcheck="false" spellcheck="false"
disabled={!peerNameReady}
bind:value={syncSetting.P2P_DevicePeerName} bind:value={syncSetting.P2P_DevicePeerName}
/> />
</InputRow> </InputRow>
@@ -385,7 +411,11 @@
disabled={!canProceed} disabled={!canProceed}
commit={() => checkAndCommit()} commit={() => checkAndCommit()}
/> />
<Decision title={translateMessage("Continue anyway")} commit={() => commit()} /> <Decision
title={translateMessage("Continue anyway")}
disabled={!peerNameReady}
commit={() => commit()}
/>
<Decision title={translateMessage("Cancel")} commit={() => cancel()} /> <Decision title={translateMessage("Cancel")} commit={() => cancel()} />
</UserDecisions> </UserDecisions>
{/if} {/if}
+4 -5
View File
@@ -1,4 +1,6 @@
import { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub"; import { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
import { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; import { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import type { ServiceInstances } from "@vrtmrz/livesync-commonlib/compat/services/ServiceHub"; import type { ServiceInstances } from "@vrtmrz/livesync-commonlib/compat/services/ServiceHub";
import type ObsidianLiveSyncPlugin from "@/main"; import type ObsidianLiveSyncPlugin from "@/main";
@@ -11,7 +13,6 @@ import {
ObsidianTweakValueService, ObsidianTweakValueService,
ObsidianTestService, ObsidianTestService,
ObsidianDatabaseEventService, ObsidianDatabaseEventService,
ObsidianConfigService,
ObsidianKeyValueDBService, ObsidianKeyValueDBService,
ObsidianControlService, ObsidianControlService,
} from "./ObsidianServices"; } from "./ObsidianServices";
@@ -46,6 +47,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
const setting = new ObsidianSettingService(context, { const setting = new ObsidianSettingService(context, {
APIService: API, APIService: API,
onDisplayLanguageChanged: setLang, onDisplayLanguageChanged: setLang,
deviceValues: createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
}); });
const appLifecycle = new ObsidianAppLifecycleService(context, { const appLifecycle = new ObsidianAppLifecycleService(context, {
settingService: setting, settingService: setting,
@@ -85,10 +87,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
databaseEvents: databaseEvents, databaseEvents: databaseEvents,
vault: vault, vault: vault,
}); });
const config = new ObsidianConfigService(context, { const config = setting.config;
settingService: setting,
APIService: API,
});
const replicator = new ObsidianReplicatorService(context, { const replicator = new ObsidianReplicatorService(context, {
settingService: setting, settingService: setting,
appLifecycleService: appLifecycle, appLifecycleService: appLifecycle,
+1 -6
View File
@@ -6,7 +6,6 @@ import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/
import { InjectableReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicatorService"; import { InjectableReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicatorService";
import { InjectableTestService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTestService"; import { InjectableTestService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTestService";
import { InjectableTweakValueService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTweakValueService"; import { InjectableTweakValueService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTweakValueService";
import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/ConfigServiceBrowserCompat";
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService"; import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService"; import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
@@ -58,10 +57,7 @@ export class ObsidianReplicatorService extends InjectableReplicatorService<Obsid
super(context, withSleepPreference(dependencies)); super(context, withSleepPreference(dependencies));
} }
async runBoundedLocalApplicationActivity<T>( async runBoundedLocalApplicationActivity<T>(task: () => T | PromiseLike<T>, options?: ActivityOptions): Promise<T> {
task: () => T | PromiseLike<T>,
options?: ActivityOptions
): Promise<T> {
this.boundedLocalApplicationActivityCount.value++; this.boundedLocalApplicationActivityCount.value++;
try { try {
return this.dependencies.activityRunner return this.dependencies.activityRunner
@@ -84,7 +80,6 @@ export class ObsidianConflictService extends InjectableConflictService<ObsidianS
export class ObsidianTweakValueService extends InjectableTweakValueService<ObsidianServiceContext> {} export class ObsidianTweakValueService extends InjectableTweakValueService<ObsidianServiceContext> {}
// InjectableTestService // InjectableTestService
export class ObsidianTestService extends InjectableTestService<ObsidianServiceContext> {} export class ObsidianTestService extends InjectableTestService<ObsidianServiceContext> {}
export class ObsidianConfigService extends ConfigServiceBrowserCompat<ObsidianServiceContext> {}
export class ObsidianKeyValueDBService extends KeyValueDBService<ObsidianServiceContext> {} export class ObsidianKeyValueDBService extends KeyValueDBService<ObsidianServiceContext> {}
@@ -1,4 +1,3 @@
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { import {
EVENT_REQUEST_RELOAD_SETTING_TAB, EVENT_REQUEST_RELOAD_SETTING_TAB,
@@ -27,19 +26,6 @@ export class ObsidianSettingService<T extends ObsidianServiceContext> extends Se
return Promise.resolve(true); return Promise.resolve(true);
}); });
} }
protected setItem(key: string, value: string) {
// TODO: Implement nativeLocalStorage.
return compatGlobal.localStorage.setItem(key, value);
}
protected getItem(key: string): string {
// TODO: Implement nativeLocalStorage.
return compatGlobal.localStorage.getItem(key) ?? "";
}
protected deleteItem(key: string): void {
// TODO: Implement nativeLocalStorage.
compatGlobal.localStorage.removeItem(key);
}
protected override async saveData(data: ObsidianLiveSyncSettings): Promise<void> { protected override async saveData(data: ObsidianLiveSyncSettings): Promise<void> {
return await this.context.liveSyncPlugin.saveData(data); return await this.context.liveSyncPlugin.saveData(data);
} }
@@ -1,6 +1,23 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import { MemoryValueStore } from "@vrtmrz/value-store";
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts"; import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts";
import { ObsidianSettingService } from "./ObsidianSettingService.ts";
function createSettingService(
vaultName: string,
deviceValues: MemoryValueStore
): ObsidianSettingService<ObsidianServiceContext> {
const context = createServiceContext() as unknown as ObsidianServiceContext;
const APIService = {
addLog: () => undefined,
getSystemVaultName: () => vaultName,
} as unknown as IAPIService;
return new ObsidianSettingService(context, { APIService, deviceValues });
}
describe("normaliseObsidianSettingsData", () => { describe("normaliseObsidianSettingsData", () => {
it("maps Obsidian's missing data value to Commonlib's new-Vault input", () => { it("maps Obsidian's missing data value to Commonlib's new-Vault input", () => {
@@ -12,4 +29,26 @@ describe("normaliseObsidianSettingsData", () => {
expect(normaliseObsidianSettingsData(settings)).toBe(settings); expect(normaliseObsidianSettingsData(settings)).toBe(settings);
}); });
it("keeps injected device values independent from each Vault-scoped configuration store", async () => {
const firstValues = new MemoryValueStore();
const secondValues = new MemoryValueStore();
const firstSetting = createSettingService("first-vault", firstValues);
const secondSetting = createSettingService("second-vault", secondValues);
await firstSetting.deviceValues.setValue("shared-key", "device value");
await firstSetting.config.setValue("shared-key", "first config");
await secondSetting.config.setValue("shared-key", "second config");
expect(firstSetting.deviceValues).toBe(firstValues);
expect(secondSetting.deviceValues).toBe(secondValues);
expect(firstSetting.deviceValues).not.toBe(secondSetting.deviceValues);
expect(firstSetting.config).not.toBe(firstValues);
expect(firstSetting.config).not.toBe(secondSetting.config);
await expect(firstSetting.deviceValues.getValue("shared-key")).resolves.toBe("device value");
await expect(firstSetting.config.getValue("shared-key")).resolves.toBe("first config");
await expect(secondSetting.config.getValue("shared-key")).resolves.toBe("second config");
await expect(firstValues.getValue("first-vault-shared-key")).resolves.toBe("first config");
await expect(secondValues.getValue("second-vault-shared-key")).resolves.toBe("second config");
});
}); });
+2 -2
View File
@@ -1,4 +1,4 @@
import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService"; import type { IConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService"; import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService"; import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService"; import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
@@ -8,7 +8,7 @@ import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy
import type { IAPIService, IControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; import type { IAPIService, IControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
export type ObsidianUIServiceDependencies<T extends ObsidianServiceContext = ObsidianServiceContext> = { export type ObsidianUIServiceDependencies<T extends ObsidianServiceContext = ObsidianServiceContext> = {
appLifecycle: AppLifecycleService<T>; appLifecycle: AppLifecycleService<T>;
config: ConfigService<T>; config: IConfigService;
replicator: ReplicatorService<T>; replicator: ReplicatorService<T>;
APIService: IAPIService; APIService: IAPIService;
control: IControlService; control: IControlService;
+9 -9
View File
@@ -44,17 +44,17 @@ export class CompatibilityReviewController {
return this._initialised; return this._initialised;
} }
private readAcknowledgedVersion(): string | null { private async readAcknowledgedVersion(): Promise<string | null> {
const setting = this.core.services.setting; const setting = this.core.services.setting;
const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY); const currentMarker = await this.core.services.config.getValue(DATABASE_COMPATIBILITY_VERSION_KEY, "");
if (currentMarker) return currentMarker; if (currentMarker) return currentMarker;
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName()); const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
const legacyMarker = setting.getDeviceLocalConfig(legacyKey); const legacyMarker = await setting.deviceValues.getValue(legacyKey, "");
if (!legacyMarker) return null; if (!legacyMarker) return null;
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker); await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker);
setting.deleteDeviceLocalConfig(legacyKey); await setting.deviceValues.delete(legacyKey);
return legacyMarker; return legacyMarker;
} }
@@ -75,7 +75,7 @@ export class CompatibilityReviewController {
return true; return true;
} }
const acknowledgedVersion = this.readAcknowledgedVersion(); const acknowledgedVersion = await this.readAcknowledgedVersion();
const evaluation = evaluateCompatibilityPause({ const evaluation = evaluateCompatibilityPause({
acknowledgedVersion, acknowledgedVersion,
currentVersion: this.currentVersion, currentVersion: this.currentVersion,
@@ -85,7 +85,7 @@ export class CompatibilityReviewController {
this.pause = evaluation.pause; this.pause = evaluation.pause;
if (evaluation.initialiseAcknowledgedVersion) { if (evaluation.initialiseAcknowledgedVersion) {
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`); await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
this._initialised = true; this._initialised = true;
return true; return true;
} }
@@ -115,9 +115,9 @@ export class CompatibilityReviewController {
throw error; throw error;
} }
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`); await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName()); const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
setting.deleteDeviceLocalConfig(legacyKey); await setting.deviceValues.delete(legacyKey);
this.pause = undefined; this.pause = undefined;
this.ui.clearReminder(); this.ui.clearReminder();
await this.core.services.control.applySettings(); await this.core.services.control.applySettings();
@@ -33,13 +33,32 @@ function createFixture(
} = {} } = {}
) { ) {
const local = new Map<string, string>(); const local = new Map<string, string>();
const deviceValues = new Map<string, string>();
if (options.marker !== undefined && options.marker !== null) { if (options.marker !== undefined && options.marker !== null) {
local.set(DATABASE_COMPATIBILITY_VERSION_KEY, options.marker); local.set(DATABASE_COMPATIBILITY_VERSION_KEY, options.marker);
} }
const legacyKey = legacyDatabaseCompatibilityVersionKey("Test Vault"); const legacyKey = legacyDatabaseCompatibilityVersionKey("Test Vault");
if (options.legacyMarker !== undefined && options.legacyMarker !== null) { if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
local.set(legacyKey, options.legacyMarker); deviceValues.set(legacyKey, options.legacyMarker);
} }
const config = {
getValue: vi.fn(async (key: string, defaultValue = "") => local.get(key) ?? defaultValue),
setValue: vi.fn(async (key: string, value: string) => {
local.set(key, value);
}),
delete: vi.fn(async (key: string) => {
local.delete(key);
}),
};
const deviceValueStore = {
getValue: vi.fn(async (key: string, defaultValue = "") => deviceValues.get(key) ?? defaultValue),
setValue: vi.fn(async (key: string, value: string) => {
deviceValues.set(key, value);
}),
delete: vi.fn(async (key: string) => {
deviceValues.delete(key);
}),
};
const settings = { const settings = {
versionUpFlash: options.versionUpFlash ?? "", versionUpFlash: options.versionUpFlash ?? "",
isConfigured: options.isConfigured ?? true, isConfigured: options.isConfigured ?? true,
@@ -49,15 +68,13 @@ function createFixture(
const setting = { const setting = {
currentSettings: () => settings, currentSettings: () => settings,
getSettingsMigrationState: () => migrationState(options.migration), getSettingsMigrationState: () => migrationState(options.migration),
getSmallConfig: (key: string) => local.get(key) ?? "", deviceValues: deviceValueStore,
setSmallConfig: (key: string, value: string) => local.set(key, value),
getDeviceLocalConfig: (key: string) => local.get(key) ?? null,
deleteDeviceLocalConfig: (key: string) => local.delete(key),
saveSettingData, saveSettingData,
}; };
const core = { const core = {
services: { services: {
setting, setting,
config,
vault: { getVaultName: () => "Test Vault" }, vault: { getVaultName: () => "Test Vault" },
control: { applySettings }, control: { applySettings },
}, },
@@ -69,7 +86,18 @@ function createFixture(
clearReminder: vi.fn(), clearReminder: vi.fn(),
}; };
const controller = new CompatibilityReviewController(core, ui, 12); const controller = new CompatibilityReviewController(core, ui, 12);
return { controller, ui, local, legacyKey, settings, saveSettingData, applySettings }; return {
controller,
ui,
local,
deviceValues,
config,
deviceValueStore,
legacyKey,
settings,
saveSettingData,
applySettings,
};
} }
describe("compatibility review controller", () => { describe("compatibility review controller", () => {
@@ -196,10 +224,96 @@ describe("compatibility review controller", () => {
await fixture.controller.initialise(); await fixture.controller.initialise();
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11"); expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
expect(fixture.local.has(fixture.legacyKey)).toBe(false); expect(fixture.deviceValues.has(fixture.legacyKey)).toBe(false);
expect(fixture.controller.pendingPause).toBeDefined(); expect(fixture.controller.pendingPause).toBeDefined();
}); });
it("saves the scoped marker before deleting the legacy device value", async () => {
const fixture = createFixture({ legacyMarker: "11" });
let finishSave!: () => void;
const saving = new Promise<void>((resolve) => {
finishSave = resolve;
});
fixture.config.setValue.mockImplementation(async (key, value) => {
await saving;
fixture.local.set(key, value);
});
const initialising = fixture.controller.initialise();
await Promise.resolve();
await Promise.resolve();
expect(fixture.deviceValues.get(fixture.legacyKey)).toBe("11");
expect(fixture.deviceValueStore.delete).not.toHaveBeenCalled();
expect(fixture.controller.initialised).toBe(false);
finishSave();
await initialising;
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
expect(fixture.deviceValues.has(fixture.legacyKey)).toBe(false);
expect(fixture.controller.initialised).toBe(true);
});
it("does not treat a failed marker read as an absent value", async () => {
const fixture = createFixture({ legacyMarker: "11" });
fixture.config.getValue.mockRejectedValueOnce(new Error("marker read failed"));
await expect(fixture.controller.initialise()).rejects.toThrow("marker read failed");
expect(fixture.deviceValueStore.getValue).not.toHaveBeenCalled();
expect(fixture.deviceValues.get(fixture.legacyKey)).toBe("11");
expect(fixture.controller.initialised).toBe(false);
});
it("keeps the compatibility pause blocked until its async marker write succeeds", async () => {
const fixture = createFixture({ marker: "11" });
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
await fixture.controller.initialise();
let finishSave!: () => void;
const saving = new Promise<void>((resolve) => {
finishSave = resolve;
});
fixture.config.setValue.mockImplementation(async (key, value) => {
await saving;
fixture.local.set(key, value);
});
const reviewing = fixture.controller.openReview();
await Promise.resolve();
await Promise.resolve();
expect(fixture.controller.pendingPause).toBeDefined();
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
expect(fixture.applySettings).not.toHaveBeenCalled();
finishSave();
await reviewing;
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.applySettings).toHaveBeenCalledOnce();
});
it("keeps a storage-failed acknowledgement blocked and retryable", async () => {
const fixture = createFixture({ marker: "11" });
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
await fixture.controller.initialise();
fixture.config.setValue.mockRejectedValueOnce(new Error("marker write failed"));
await expect(fixture.controller.openReview()).rejects.toThrow("marker write failed");
expect(fixture.controller.pendingPause).toBeDefined();
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
expect(fixture.applySettings).not.toHaveBeenCalled();
await fixture.controller.openReview();
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
expect(fixture.applySettings).toHaveBeenCalledOnce();
});
it("restores the runtime gate if persisting an acknowledgement fails", async () => { it("restores the runtime gate if persisting an acknowledgement fails", async () => {
const fixture = createFixture({ marker: "11" }); const fixture = createFixture({ marker: "11" });
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume"); vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
+18 -19
View File
@@ -63,28 +63,27 @@ function buildSimpleFetchResult(stage1: string, stage2?: string) {
return undefined; return undefined;
} }
function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) { async function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) {
host.services.setting.setSmallConfig(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 })); await host.services.setting.config.setValue(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 }));
} }
function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) { async function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
const saved = host.services.setting.getSmallConfig(SIMPLE_FETCH_MODE_KEY); const saved = await host.services.setting.config.getValue(SIMPLE_FETCH_MODE_KEY, "");
if (!saved) return undefined; if (!saved) return undefined;
let remembered: ReturnType<typeof buildSimpleFetchResult> = undefined;
try { try {
const { stage1, stage2 } = JSON.parse(saved) as { stage1?: string; stage2?: string }; const { stage1, stage2 } = JSON.parse(saved) as { stage1?: string; stage2?: string };
if (stage1) { if (stage1) remembered = buildSimpleFetchResult(stage1, stage2);
const remembered = buildSimpleFetchResult(stage1, stage2);
if (remembered) return remembered;
}
} catch { } catch {
// Clear below; the saved choice is optional and can be rebuilt by asking again. // Clear below; the saved choice is optional and can be rebuilt by asking again.
} }
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY); if (remembered) return remembered;
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
return undefined; return undefined;
} }
function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) { async function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY); await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
} }
async function confirmTranslatedChoice( async function confirmTranslatedChoice(
@@ -104,7 +103,7 @@ async function confirmTranslatedChoice(
export async function askSimpleFetchMode( export async function askSimpleFetchMode(
host: NecessaryServices<"UI" | "setting", never> host: NecessaryServices<"UI" | "setting", never>
): Promise<{ mode: string; options: Partial<FullScanOptions> } | "cancelled" | "aborted"> { ): Promise<{ mode: string; options: Partial<FullScanOptions> } | "cancelled" | "aborted"> {
const remembered = getRememberedSimpleFetchMode(host); const remembered = await getRememberedSimpleFetchMode(host);
if (remembered) return remembered; if (remembered) return remembered;
const msg = $msg( const msg = $msg(
@@ -169,7 +168,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
if (stage2 === STAGE2_ABORT) { if (stage2 === STAGE2_ABORT) {
return "aborted"; return "aborted";
} }
rememberSimpleFetchMode(host, stage1, stage2); await rememberSimpleFetchMode(host, stage1, stage2);
return buildSimpleFetchResult(stage1, stage2)!; return buildSimpleFetchResult(stage1, stage2)!;
} }
@@ -198,7 +197,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
if (stage2 === STAGE2_ABORT) { if (stage2 === STAGE2_ABORT) {
return "aborted"; return "aborted";
} }
rememberSimpleFetchMode(host, stage1, stage2); await rememberSimpleFetchMode(host, stage1, stage2);
return buildSimpleFetchResult(stage1, stage2)!; return buildSimpleFetchResult(stage1, stage2)!;
} }
@@ -234,18 +233,18 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
"Remediation mode is active, so the detailed fetch flow is used instead of Simple Fetch.", "Remediation mode is active, so the detailed fetch flow is used instead of Simple Fetch.",
LOG_LEVEL_NOTICE LOG_LEVEL_NOTICE
); );
clearRememberedSimpleFetchMode(host); await clearRememberedSimpleFetchMode(host);
return undefined; return undefined;
} }
const result = await askSimpleFetchMode(host); const result = await askSimpleFetchMode(host);
if (result === "cancelled") { if (result === "cancelled") {
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE); log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
clearRememberedSimpleFetchMode(host); await clearRememberedSimpleFetchMode(host);
return await cancelScheduledInitialisation(host, cleanupFlag); return await cancelScheduledInitialisation(host, cleanupFlag);
} }
if (result === "aborted") { if (result === "aborted") {
log("Fetch exited by user.", LOG_LEVEL_NOTICE); log("Fetch exited by user.", LOG_LEVEL_NOTICE);
clearRememberedSimpleFetchMode(host); await clearRememberedSimpleFetchMode(host);
host.services.appLifecycle.performRestart(); host.services.appLifecycle.performRestart();
return false; return false;
} }
@@ -256,7 +255,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
const settings = host.services.setting.currentSettings(); const settings = host.services.setting.currentSettings();
if (!(await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings))) { if (!(await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings))) {
log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE); log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE);
clearRememberedSimpleFetchMode(host); await clearRememberedSimpleFetchMode(host);
return await cancelScheduledInitialisation(host, cleanupFlag); return await cancelScheduledInitialisation(host, cleanupFlag);
} }
@@ -295,7 +294,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
} }
await host.serviceModules.rebuilder.finishRebuild(); await host.serviceModules.rebuilder.finishRebuild();
await cleanupFlag(); await cleanupFlag();
clearRememberedSimpleFetchMode(host); await clearRememberedSimpleFetchMode(host);
log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE); log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE);
return true; return true;
}; };
@@ -10,19 +10,26 @@ const KEEP_LOCAL = "Keep local files even if not on remote";
function createHost(savedChoice?: string) { function createHost(savedChoice?: string) {
const smallConfig = new Map<string, string>(); const smallConfig = new Map<string, string>();
if (savedChoice) smallConfig.set("simple-fetch-mode", savedChoice); if (savedChoice) smallConfig.set("simple-fetch-mode", savedChoice);
const config = {
getValue: vi.fn(async (key: string, defaultValue = "") => smallConfig.get(key) ?? defaultValue),
setValue: vi.fn(async (key: string, value: string) => {
smallConfig.set(key, value);
}),
delete: vi.fn(async (key: string) => {
smallConfig.delete(key);
}),
};
const confirmWithMessage = const confirmWithMessage =
vi.fn<(title: string, message: string, buttons: string[], defaultAction: string) => Promise<string | false>>(); vi.fn<(title: string, message: string, buttons: string[], defaultAction: string) => Promise<string | false>>();
const host = { const host = {
services: { services: {
UI: { confirm: { confirmWithMessage } }, UI: { confirm: { confirmWithMessage } },
setting: { setting: {
getSmallConfig: (key: string) => smallConfig.get(key) ?? "", config,
setSmallConfig: (key: string, value: string) => smallConfig.set(key, value),
deleteSmallConfig: (key: string) => smallConfig.delete(key),
}, },
}, },
} as unknown as NecessaryServices<"UI" | "setting", never>; } as unknown as NecessaryServices<"UI" | "setting", never>;
return { host, confirmWithMessage, smallConfig }; return { host, confirmWithMessage, smallConfig, config };
} }
afterEach(() => setLang("def")); afterEach(() => setLang("def"));
@@ -50,6 +57,75 @@ describe("Fast Setup translated choices", () => {
}); });
}); });
it("waits for the saved choice before returning it to the fetch workflow", async () => {
const { host, confirmWithMessage, smallConfig, config } = createHost();
let markSaveStarted!: () => void;
const saveStarted = new Promise<void>((resolve) => {
markSaveStarted = resolve;
});
let finishSave!: () => void;
const save = new Promise<void>((resolve) => {
finishSave = resolve;
});
config.setValue.mockImplementation(async (key, value) => {
markSaveStarted();
await save;
smallConfig.set(key, value);
});
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[1]);
const asking = askSimpleFetchMode(host);
await saveStarted;
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
expect(confirmWithMessage).toHaveBeenCalledTimes(2);
finishSave();
await expect(asking).resolves.toMatchObject({ mode: "remote-only" });
expect(JSON.parse(smallConfig.get("simple-fetch-mode")!)).toEqual({
stage1: REMOTE_WINS,
stage2: KEEP_LOCAL,
});
});
it("propagates a stored-choice read failure instead of treating it as missing", async () => {
const { host, confirmWithMessage, config } = createHost();
config.getValue.mockRejectedValueOnce(new Error("choice read failed"));
await expect(askSimpleFetchMode(host)).rejects.toThrow("choice read failed");
expect(confirmWithMessage).not.toHaveBeenCalled();
});
it("waits for an invalid optional choice to be deleted before asking again", async () => {
const { host, confirmWithMessage, smallConfig, config } = createHost("not-json");
let markDeleteStarted!: () => void;
const deleteStarted = new Promise<void>((resolve) => {
markDeleteStarted = resolve;
});
let finishDelete!: () => void;
const deletion = new Promise<void>((resolve) => {
finishDelete = resolve;
});
config.delete.mockImplementation(async (key) => {
markDeleteStarted();
await deletion;
smallConfig.delete(key);
});
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[3]);
const asking = askSimpleFetchMode(host);
await deleteStarted;
expect(confirmWithMessage).not.toHaveBeenCalled();
expect(smallConfig.has("simple-fetch-mode")).toBe(true);
finishDelete();
await expect(asking).resolves.toBe("cancelled");
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
expect(confirmWithMessage).toHaveBeenCalledOnce();
});
it("restores choices saved by the previous English implementation while displaying Spanish", async () => { it("restores choices saved by the previous English implementation while displaying Spanish", async () => {
const savedChoice = JSON.stringify({ stage1: REMOTE_WINS, stage2: KEEP_LOCAL }); const savedChoice = JSON.stringify({ stage1: REMOTE_WINS, stage2: KEEP_LOCAL });
const { host, confirmWithMessage, smallConfig } = createHost(savedChoice); const { host, confirmWithMessage, smallConfig } = createHost(savedChoice);
+12 -10
View File
@@ -107,13 +107,15 @@ const createSettingServiceMock = () => {
}), }),
suspendAllSync: vi.fn(() => Promise.resolve()), suspendAllSync: vi.fn(() => Promise.resolve()),
suspendExtraSync: vi.fn(() => Promise.resolve()), suspendExtraSync: vi.fn(() => Promise.resolve()),
getSmallConfig: vi.fn((key: string) => smallConfig.get(key) ?? ""), config: {
setSmallConfig: vi.fn((key: string, value: string) => { getValue: vi.fn(async (key: string, defaultValue = "") => smallConfig.get(key) ?? defaultValue),
smallConfig.set(key, value); setValue: vi.fn(async (key: string, value: string) => {
}), smallConfig.set(key, value);
deleteSmallConfig: vi.fn((key: string) => { }),
smallConfig.delete(key); delete: vi.fn(async (key: string) => {
}), smallConfig.delete(key);
}),
},
}; };
}; };
@@ -785,7 +787,7 @@ describe("Red Flag Feature", () => {
); );
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled(); expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode"); expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
expect(cleanupFlag).not.toHaveBeenCalled(); expect(cleanupFlag).not.toHaveBeenCalled();
}); });
@@ -830,7 +832,7 @@ describe("Red Flag Feature", () => {
}); });
expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled(); expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled();
expect(cleanupFlag).not.toHaveBeenCalled(); expect(cleanupFlag).not.toHaveBeenCalled();
expect(host.mocks.setting.deleteSmallConfig).not.toHaveBeenCalledWith("simple-fetch-mode"); expect(host.mocks.setting.config.delete).not.toHaveBeenCalledWith("simple-fetch-mode");
}); });
it("re-suspends both reflection directions when finalisation fails after releasing them", async () => { it("re-suspends both reflection directions when finalisation fails after releasing them", async () => {
@@ -892,7 +894,7 @@ describe("Red Flag Feature", () => {
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag); await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode"); expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
}); });
it("should return false and cleanup when quick flow is cancelled", async () => { it("should return false and cleanup when quick flow is cancelled", async () => {
+7 -7
View File
@@ -69,9 +69,9 @@ export function useReviewHarness(
await createFileContentHashBenchmarkOperations(activeWindow.performance), await createFileContentHashBenchmarkOperations(activeWindow.performance),
activeWindow.performance activeWindow.performance
), ),
readContinuation: () => services.setting.getSmallConfig(REVIEW_HARNESS_STATE_KEY), readContinuation: () => services.config.getValue(REVIEW_HARNESS_STATE_KEY, ""),
writeContinuation: (value) => services.setting.setSmallConfig(REVIEW_HARNESS_STATE_KEY, value), writeContinuation: (value) => services.config.setValue(REVIEW_HARNESS_STATE_KEY, value),
deleteContinuation: () => services.setting.deleteSmallConfig(REVIEW_HARNESS_STATE_KEY), deleteContinuation: () => services.config.delete(REVIEW_HARNESS_STATE_KEY),
restart: () => services.appLifecycle.performRestart(), restart: () => services.appLifecycle.performRestart(),
reportError: (error) => services.API.addLog(error, LOG_LEVEL_NOTICE), reportError: (error) => services.API.addLog(error, LOG_LEVEL_NOTICE),
copyText: async (value) => { copyText: async (value) => {
@@ -94,14 +94,14 @@ export function useReviewHarness(
let registered = false; let registered = false;
let openAfterLayout = false; let openAfterLayout = false;
services.appLifecycle.onSettingLoaded.addHandler(() => { services.appLifecycle.onSettingLoaded.addHandler(async () => {
if (!continuationConsumed) { if (!continuationConsumed) {
await controller.consumeContinuation();
continuationConsumed = true; continuationConsumed = true;
controller.consumeContinuation();
} }
const snapshot = controller.snapshot(); const snapshot = controller.snapshot();
openAfterLayout = snapshot.resumedRequestId !== null || snapshot.continuationError !== null; openAfterLayout = snapshot.resumedRequestId !== null || snapshot.continuationError !== null;
if (!services.setting.currentSettings().enableDebugTools || registered) return Promise.resolve(true); if (!services.setting.currentSettings().enableDebugTools || registered) return true;
registered = true; registered = true;
services.API.registerWindow( services.API.registerWindow(
@@ -115,7 +115,7 @@ export function useReviewHarness(
void services.API.showWindow(VIEW_TYPE_REVIEW_HARNESS); void services.API.showWindow(VIEW_TYPE_REVIEW_HARNESS);
}, },
}); });
return Promise.resolve(true); return true;
}); });
services.appLifecycle.onLayoutReady.addHandler(() => { services.appLifecycle.onLayoutReady.addHandler(() => {
@@ -31,6 +31,15 @@ function createFixture(options: { enableDebugTools?: boolean; continuation?: str
const layoutReadyHandlers: Array<() => Promise<boolean>> = []; const layoutReadyHandlers: Array<() => Promise<boolean>> = [];
const local = new Map<string, string>(); const local = new Map<string, string>();
if (options.continuation) local.set(REVIEW_HARNESS_STATE_KEY, options.continuation); if (options.continuation) local.set(REVIEW_HARNESS_STATE_KEY, options.continuation);
const config = {
getValue: vi.fn(async (key: string, defaultValue = "") => local.get(key) ?? defaultValue),
setValue: vi.fn(async (key: string, value: string) => {
local.set(key, value);
}),
delete: vi.fn(async (key: string) => {
local.delete(key);
}),
};
const settings = { const settings = {
enableDebugTools: options.enableDebugTools ?? true, enableDebugTools: options.enableDebugTools ?? true,
liveSync: false, liveSync: false,
@@ -63,10 +72,8 @@ function createFixture(options: { enableDebugTools?: boolean; continuation?: str
requiresSyncReview: true, requiresSyncReview: true,
reviewReasons: [], reviewReasons: [],
}), }),
getSmallConfig: (key: string) => local.get(key) ?? "",
setSmallConfig: (key: string, value: string) => local.set(key, value),
deleteSmallConfig: (key: string) => local.delete(key),
}, },
config,
appLifecycle: { appLifecycle: {
onSettingLoaded: { onSettingLoaded: {
addHandler: vi.fn((handler: () => Promise<boolean>) => settingLoadedHandlers.push(handler)), addHandler: vi.fn((handler: () => Promise<boolean>) => settingLoadedHandlers.push(handler)),
@@ -106,6 +113,7 @@ function createFixture(options: { enableDebugTools?: boolean; continuation?: str
api, api,
settings, settings,
local, local,
config,
settingLoadedHandlers, settingLoadedHandlers,
layoutReadyHandlers, layoutReadyHandlers,
compatibilityReview, compatibilityReview,
@@ -144,6 +152,74 @@ describe("Review Harness composition", () => {
expect(fixture.api.showWindow).toHaveBeenCalledWith(VIEW_TYPE_REVIEW_HARNESS); expect(fixture.api.showWindow).toHaveBeenCalledWith(VIEW_TYPE_REVIEW_HARNESS);
}); });
it("holds the setting-loaded lifecycle until continuation deletion has completed", async () => {
const requestedAt = "2026-07-18T11:59:00.000Z";
const continuation = JSON.stringify({
formatVersion: 1,
requestId: `compatibility-review-${requestedAt}`,
scenarioId: "compatibility-review",
stage: "awaiting-restart",
requestedAt,
});
const fixture = createFixture({ continuation });
let markDeleteStarted!: () => void;
const deleteStarted = new Promise<void>((resolve) => {
markDeleteStarted = resolve;
});
let finishDelete!: () => void;
const deleting = new Promise<void>((resolve) => {
finishDelete = resolve;
});
fixture.config.delete.mockImplementation(async (key) => {
markDeleteStarted();
await deleting;
fixture.local.delete(key);
});
const loading = fixture.settingLoadedHandlers[0]();
await deleteStarted;
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(true);
expect(fixture.controller.snapshot().resumedRequestId).toBeNull();
expect(fixture.api.registerWindow).not.toHaveBeenCalled();
finishDelete();
await loading;
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(false);
expect(fixture.controller.snapshot().resumedRequestId).toBe(`compatibility-review-${requestedAt}`);
expect(fixture.api.registerWindow).toHaveBeenCalledOnce();
});
it("can retry one-shot continuation consumption after a deletion failure", async () => {
const requestedAt = "2026-07-18T11:59:00.000Z";
const continuation = JSON.stringify({
formatVersion: 1,
requestId: `compatibility-review-${requestedAt}`,
scenarioId: "compatibility-review",
stage: "awaiting-restart",
requestedAt,
});
const fixture = createFixture({ continuation });
fixture.config.delete
.mockRejectedValueOnce(new Error("continuation delete failed"))
.mockImplementation(async (key) => {
fixture.local.delete(key);
});
await expect(fixture.settingLoadedHandlers[0]()).rejects.toThrow("continuation delete failed");
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(true);
expect(fixture.controller.snapshot().resumedRequestId).toBeNull();
expect(fixture.api.registerWindow).not.toHaveBeenCalled();
await fixture.settingLoadedHandlers[0]();
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(false);
expect(fixture.controller.snapshot().resumedRequestId).toBe(`compatibility-review-${requestedAt}`);
expect(fixture.api.registerWindow).toHaveBeenCalledOnce();
});
it("uses the actual compatibility controller as the guided review boundary", async () => { it("uses the actual compatibility controller as the guided review boundary", async () => {
const fixture = createFixture(); const fixture = createFixture();
+9 -8
View File
@@ -1,3 +1,4 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { LOG_LEVEL_INFO, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { LOG_LEVEL_INFO, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LiveSyncBrowserServiceHubOptions } from "@/apps/browser/createLiveSyncBrowserServiceHub"; import type { LiveSyncBrowserServiceHubOptions } from "@/apps/browser/createLiveSyncBrowserServiceHub";
@@ -168,7 +169,7 @@ describe("WebAppRuntime lifecycle", () => {
it("owns core start, directory scan, status reporting, and shutdown", async () => { it("owns core start, directory scan, status reporting, and shutdown", async () => {
const reportStatus = vi.fn(); const reportStatus = vi.fn();
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus }); const runtime = new WebAppRuntime(createRootHandle(), { reportStatus, deviceValues: new MemoryValueStore() });
await runtime.start(); await runtime.start();
@@ -193,7 +194,7 @@ describe("WebAppRuntime lifecycle", () => {
}); });
it("finalises application readiness after importing local files for optional P2P", async () => { it("finalises application readiness after importing local files for optional P2P", async () => {
const runtime = new WebAppRuntime(createRootHandle()); const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
await runtime.start(); await runtime.start();
vi.clearAllMocks(); vi.clearAllMocks();
runtimeMocks.currentSettings.mockReturnValue(unconfiguredSettings); runtimeMocks.currentSettings.mockReturnValue(unconfiguredSettings);
@@ -239,7 +240,7 @@ describe("WebAppRuntime lifecycle", () => {
}); });
it("does not publish readiness after a partial local-file import", async () => { it("does not publish readiness after a partial local-file import", async () => {
const runtime = new WebAppRuntime(createRootHandle()); const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
await runtime.start(); await runtime.start();
vi.clearAllMocks(); vi.clearAllMocks();
runtimeMocks.collectFilesOnStorage.mockResolvedValue({ runtimeMocks.collectFilesOnStorage.mockResolvedValue({
@@ -269,7 +270,7 @@ describe("WebAppRuntime lifecycle", () => {
}); });
it("does not publish readiness when database initialisation rejects manual preparation", async () => { it("does not publish readiness when database initialisation rejects manual preparation", async () => {
const runtime = new WebAppRuntime(createRootHandle()); const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
await runtime.start(); await runtime.start();
vi.clearAllMocks(); vi.clearAllMocks();
runtimeMocks.onDatabaseInitialised.mockResolvedValue(false); runtimeMocks.onDatabaseInitialised.mockResolvedValue(false);
@@ -282,7 +283,7 @@ describe("WebAppRuntime lifecycle", () => {
}); });
it("does not publish readiness when pending file events cannot be committed", async () => { it("does not publish readiness when pending file events cannot be committed", async () => {
const runtime = new WebAppRuntime(createRootHandle()); const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
await runtime.start(); await runtime.start();
vi.clearAllMocks(); vi.clearAllMocks();
runtimeMocks.commitPendingFileEvents.mockResolvedValue(false); runtimeMocks.commitPendingFileEvents.mockResolvedValue(false);
@@ -295,7 +296,7 @@ describe("WebAppRuntime lifecycle", () => {
}); });
it("does not repeat readiness finalisation after a successful manual scan", async () => { it("does not repeat readiness finalisation after a successful manual scan", async () => {
const runtime = new WebAppRuntime(createRootHandle()); const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
await runtime.start(); await runtime.start();
vi.clearAllMocks(); vi.clearAllMocks();
@@ -311,7 +312,7 @@ describe("WebAppRuntime lifecycle", () => {
it("rejects a failed core start after cleaning up the partial runtime", async () => { it("rejects a failed core start after cleaning up the partial runtime", async () => {
runtimeMocks.onLoad.mockResolvedValue(false); runtimeMocks.onLoad.mockResolvedValue(false);
const reportStatus = vi.fn(); const reportStatus = vi.fn();
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus }); const runtime = new WebAppRuntime(createRootHandle(), { reportStatus, deviceValues: new MemoryValueStore() });
await expect(runtime.start()).rejects.toThrow("Failed to initialise LiveSync"); await expect(runtime.start()).rejects.toThrow("Failed to initialise LiveSync");
@@ -325,7 +326,7 @@ describe("WebAppRuntime lifecycle", () => {
it("shuts down once before scheduling a host reload", async () => { it("shuts down once before scheduling a host reload", async () => {
const scheduleReload = vi.fn(); const scheduleReload = vi.fn();
const runtime = new WebAppRuntime(createRootHandle(), { scheduleReload }); const runtime = new WebAppRuntime(createRootHandle(), { scheduleReload, deviceValues: new MemoryValueStore() });
await runtime.start(); await runtime.start();
runtimeMocks.options?.restart?.schedule(); runtimeMocks.options?.restart?.schedule();
+35 -6
View File
@@ -1,3 +1,4 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { DEFAULT_SETTINGS, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { DEFAULT_SETTINGS, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
@@ -27,6 +28,7 @@ describe("WebPeer runtime composition", () => {
translate: (key) => `webpeer:${key}`, translate: (key) => `webpeer:${key}`,
}); });
const runtime = new WebPeerRuntime({ const runtime = new WebPeerRuntime({
deviceValues: new MemoryValueStore(),
context, context,
store: createMemoryStore(), store: createMemoryStore(),
}); });
@@ -43,6 +45,7 @@ describe("WebPeer runtime composition", () => {
it("loads settings and opens the local database before announcing layout readiness", async () => { it("loads settings and opens the local database before announcing layout readiness", async () => {
const runtime = new WebPeerRuntime({ const runtime = new WebPeerRuntime({
deviceValues: new MemoryValueStore(),
store: createMemoryStore(), store: createMemoryStore(),
}); });
const loadSettings = vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined); const loadSettings = vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
@@ -68,6 +71,7 @@ describe("WebPeer runtime composition", () => {
vi.useFakeTimers(); vi.useFakeTimers();
try { try {
const runtime = new WebPeerRuntime({ const runtime = new WebPeerRuntime({
deviceValues: new MemoryValueStore(),
store: createMemoryStore(), store: createMemoryStore(),
}); });
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined); vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
@@ -77,9 +81,7 @@ describe("WebPeer runtime composition", () => {
P2P_Enabled: true, P2P_Enabled: true,
}); });
vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(true); vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(true);
const onResumed = vi const onResumed = vi.spyOn(runtime.services.appLifecycle, "onResumed").mockResolvedValue(true);
.spyOn(runtime.services.appLifecycle, "onResumed")
.mockResolvedValue(true);
const open = vi.spyOn(runtime.p2p.transportLifecycle, "connect").mockResolvedValue(undefined); const open = vi.spyOn(runtime.p2p.transportLifecycle, "connect").mockResolvedValue(undefined);
await runtime.start(); await runtime.start();
@@ -94,9 +96,11 @@ describe("WebPeer runtime composition", () => {
it("rejects start when the browser-local database cannot be opened", async () => { it("rejects start when the browser-local database cannot be opened", async () => {
const runtime = new WebPeerRuntime({ const runtime = new WebPeerRuntime({
deviceValues: new MemoryValueStore(),
store: createMemoryStore(), store: createMemoryStore(),
}); });
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined); vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({ ...DEFAULT_SETTINGS });
vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(false); vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(false);
await expect(runtime.start()).rejects.toThrow("WebPeer local database could not be opened"); await expect(runtime.start()).rejects.toThrow("WebPeer local database could not be opened");
@@ -104,6 +108,7 @@ describe("WebPeer runtime composition", () => {
it("isolates a specialised runtime and applies its device name before opening the database", async () => { it("isolates a specialised runtime and applies its device name before opening the database", async () => {
const runtime = new WebPeerRuntime({ const runtime = new WebPeerRuntime({
deviceValues: new MemoryValueStore(),
store: createMemoryStore(), store: createMemoryStore(),
deviceName: " p2p-check-browser-desktop-abc ", deviceName: " p2p-check-browser-desktop-abc ",
systemVaultName: "p2p-check-vault", systemVaultName: "p2p-check-vault",
@@ -114,15 +119,39 @@ describe("WebPeer runtime composition", () => {
P2P_AutoStart: false, P2P_AutoStart: false,
P2P_Enabled: false, P2P_Enabled: false,
}); });
const setSmallConfig = vi.spyOn(runtime.services.config, "setSmallConfig").mockImplementation(() => undefined); let finishWrite!: () => void;
const setValue = vi.spyOn(runtime.services.config, "setValue").mockImplementation(
() =>
new Promise<void>((resolve) => {
finishWrite = resolve;
})
);
const openDatabase = vi.spyOn(runtime.services.database, "openDatabase").mockImplementation(async () => { const openDatabase = vi.spyOn(runtime.services.database, "openDatabase").mockImplementation(async () => {
expect(setSmallConfig).toHaveBeenCalledWith(SETTING_KEY_P2P_DEVICE_NAME, "p2p-check-browser-desktop-abc"); expect(setValue).toHaveBeenCalledWith(SETTING_KEY_P2P_DEVICE_NAME, "p2p-check-browser-desktop-abc");
return true; return true;
}); });
await runtime.start(); const starting = runtime.start();
await vi.waitFor(() => expect(setValue).toHaveBeenCalledOnce());
expect(openDatabase).not.toHaveBeenCalled();
finishWrite();
await starting;
expect(runtime.services.API.getSystemVaultName()).toBe("p2p-check-vault"); expect(runtime.services.API.getSystemVaultName()).toBe("p2p-check-vault");
expect(openDatabase).toHaveBeenCalledOnce(); expect(openDatabase).toHaveBeenCalledOnce();
}); });
it("reads the current device name after the pane updates the store", async () => {
const runtime = new WebPeerRuntime({
deviceValues: new MemoryValueStore(),
store: createMemoryStore(),
systemVaultName: "webpeer-vault",
});
vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({ ...DEFAULT_SETTINGS });
expect(await runtime.getDeviceName()).toBe("webpeer-vault");
await runtime.services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, "first-name");
expect(await runtime.getDeviceName()).toBe("first-name");
await runtime.services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, "renamed-device");
expect(await runtime.getDeviceName()).toBe("renamed-device");
});
}); });
+2 -3
View File
@@ -17,13 +17,12 @@ export const SERVICE_CONTEXT_MEMBERS = [
"vault", "vault",
"test", "test",
"UI", "UI",
"config",
"keyValueDB", "keyValueDB",
"control", "control",
] as const satisfies readonly Exclude<keyof ServiceHub, "context">[]; ] as const satisfies readonly Exclude<keyof ServiceHub, "context" | "config">[];
export type ServiceContextMember = (typeof SERVICE_CONTEXT_MEMBERS)[number]; export type ServiceContextMember = (typeof SERVICE_CONTEXT_MEMBERS)[number];
type MissingServiceContextMember = Exclude<Exclude<keyof ServiceHub, "context">, ServiceContextMember>; type MissingServiceContextMember = Exclude<Exclude<keyof ServiceHub, "context" | "config">, ServiceContextMember>;
const serviceContextMembersAreExhaustive: [MissingServiceContextMember] extends [never] ? true : never = true; const serviceContextMembersAreExhaustive: [MissingServiceContextMember] extends [never] ? true : never = true;
void serviceContextMembersAreExhaustive; void serviceContextMembersAreExhaustive;
+2 -2
View File
@@ -111,7 +111,7 @@ export async function readE2eCompatibilityMarker(
return await evalObsidianJson<CompatibilityMarkerState>( return await evalObsidianJson<CompatibilityMarkerState>(
cliBinary, cliBinary,
[ [
"(()=>{", "(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;", "const core=app.plugins.plugins['obsidian-livesync'].core;",
"const setting=core.services.setting;", "const setting=core.services.setting;",
"const settings=setting.currentSettings();", "const settings=setting.currentSettings();",
@@ -122,7 +122,7 @@ export async function readE2eCompatibilityMarker(
"return JSON.stringify({", "return JSON.stringify({",
"vaultName,additionalSuffix,expectedStorageKey,", "vaultName,additionalSuffix,expectedStorageKey,",
"rawStorageValue:localStorage.getItem(expectedStorageKey),", "rawStorageValue:localStorage.getItem(expectedStorageKey),",
"serviceValue:setting.getSmallConfig(markerKey),", "serviceValue:await setting.config.getValue(markerKey,''),",
"versionUpFlash:settings.versionUpFlash,", "versionUpFlash:settings.versionUpFlash,",
"});", "});",
"})()", "})()",
+2 -2
View File
@@ -288,7 +288,7 @@ export async function readRuntimeUpgradeState(
"localDatabaseDocumentCount:databaseInfo.doc_count,", "localDatabaseDocumentCount:databaseInfo.doc_count,",
"nodeId:nodeInfo?.nodeid??replicator?.nodeid??'',", "nodeId:nodeInfo?.nodeid??replicator?.nodeid??'',",
"legacyCompatibilityMarker:localStorage.getItem(`obsidian-live-sync-ver${vaultName}`),", "legacyCompatibilityMarker:localStorage.getItem(`obsidian-live-sync-ver${vaultName}`),",
"compatibilityMarker:setting.getSmallConfig('database-compatibility-version')??'',", "compatibilityMarker:await (setting.config?.getValue?.('database-compatibility-version','')??Promise.resolve(setting.getSmallConfig?.('database-compatibility-version')??'')),",
"compatibilityStorageEntries:Object.fromEntries(Array.from({length:localStorage.length},(_,index)=>localStorage.key(index))", "compatibilityStorageEntries:Object.fromEntries(Array.from({length:localStorage.length},(_,index)=>localStorage.key(index))",
".filter((key)=>key!==null)", ".filter((key)=>key!==null)",
".filter(key=>key.startsWith('obsidian-live-sync-ver')||key.endsWith('-database-compatibility-version'))", ".filter(key=>key.startsWith('obsidian-live-sync-ver')||key.endsWith('-database-compatibility-version'))",
@@ -335,7 +335,7 @@ export async function readRuntimeSettingsUpgradeState(
"const migrationState=setting.getSettingsMigrationState?.();", "const migrationState=setting.getSettingsMigrationState?.();",
"return JSON.stringify({", "return JSON.stringify({",
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version??'unknown',", "pluginVersion:app.plugins.manifests['obsidian-livesync']?.version??'unknown',",
"compatibilityMarker:setting.getSmallConfig?.('database-compatibility-version')??'',", "compatibilityMarker:await (setting.config?.getValue?.('database-compatibility-version','')??Promise.resolve(setting.getSmallConfig?.('database-compatibility-version')??'')),",
"migrationState,", "migrationState,",
"settings:{", "settings:{",
"isConfigured:settings.isConfigured,settingVersion:settings.settingVersion,", "isConfigured:settings.isConfigured,settingVersion:settings.settingVersion,",
+122 -60
View File
@@ -1,7 +1,9 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import type { Page } from "playwright";
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri"; import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
import { decryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption"; import { decryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption";
import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { $msg } from "../../../src/common/translation.ts"; import { $msg } from "../../../src/common/translation.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { createE2eCouchDbPluginData, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; import { createE2eCouchDbPluginData, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
@@ -13,18 +15,34 @@ import {
captureObsidianPage, captureObsidianPage,
obsidianRemoteDebuggingPort, obsidianRemoteDebuggingPort,
openLiveSyncSettings, openLiveSyncSettings,
withObsidianPage, withObsidianPage as withObsidianSessionPage,
} from "../runner/ui.ts"; } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts"; import { createTemporaryVault } from "../runner/vault.ts";
const dialogRunStateKey = "__livesyncE2EDialogMount"; const dialogRunStateKey = "__livesyncE2EDialogMount";
const savedP2PDeviceName = "e2e-p2p-saved-device";
const repairRunStateKey = "__livesyncE2ETroubleshootingRepair"; const repairRunStateKey = "__livesyncE2ETroubleshootingRepair";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_DIALOG_TIMEOUT_MS ?? 10000); const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_DIALOG_TIMEOUT_MS ?? 10000);
async function withObsidianPage<T>(port: number, operation: (page: Page) => Promise<T>): Promise<T> {
return await withObsidianSessionPage(port, async (page) => {
for (const candidate of page.context().pages()) {
const hasPlugin = await candidate
.evaluate(
() => (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"] !== undefined
)
.catch(() => false);
if (hasPlugin) return await operation(candidate);
}
throw new Error("The LiveSync Vault renderer is unavailable.");
});
}
type DialogueMode = "desktop" | "mobile"; type DialogueMode = "desktop" | "mobile";
type DialogueRunState = { type DialogueRunState = {
done: boolean; done: boolean;
deviceNameRead?: boolean;
error?: string; error?: string;
expected?: unknown; expected?: unknown;
kind: string; kind: string;
@@ -46,6 +64,14 @@ type LiveSyncTestPlugin = {
createAllChunks(force: boolean): Promise<unknown>; createAllChunks(force: boolean): Promise<unknown>;
}; };
modules: SetupManagerHandle[]; modules: SetupManagerHandle[];
services: {
setting: {
config: {
getValue(key: string, defaultValue?: string): Promise<string>;
setValue(key: string, value: string): Promise<void>;
};
};
};
settings: Record<string, unknown>; settings: Record<string, unknown>;
}; };
}; };
@@ -91,39 +117,63 @@ async function openRemoteSelectionDialogue(): Promise<void> {
async function openP2PRemoteSelectionDialogueForInspection(): Promise<void> { async function openP2PRemoteSelectionDialogueForInspection(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate((stateKey) => { await page.evaluate(
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; async ({ stateKey, deviceNameKey, deviceName }) => {
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager"); if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
if ( const initialPeerName = plugin.core.settings.P2P_DevicePeerName;
typeof manager?.onSelectServer !== "function" || if (typeof initialPeerName === "string" && initialPeerName.trim() !== "") {
typeof manager.onConfirmApplySettingsFromWizard !== "function" throw new Error("The P2P setup fixture unexpectedly has an initial device name.");
) { }
throw new Error("Could not find the P2P setup workflow"); const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager");
if (
typeof manager?.onSelectServer !== "function" ||
typeof manager.onConfirmApplySettingsFromWizard !== "function"
) {
throw new Error("Could not find the P2P setup workflow");
}
const config = plugin.core.services.setting.config;
await config.setValue(deviceNameKey, deviceName);
const originalGetValue = config.getValue;
const originalConfirm = manager.onConfirmApplySettingsFromWizard;
const state: DialogueRunState = {
kind: "p2p-compatibility-settings",
done: false,
deviceNameRead: false,
};
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
config.getValue = async (key, defaultValue) => {
const value = await originalGetValue.call(config, key, defaultValue);
if (key === deviceNameKey && value === deviceName) state.deviceNameRead = true;
return value;
};
manager.onConfirmApplySettingsFromWizard = async (settings: unknown) => {
state.expected = settings;
return true;
};
void manager
.onSelectServer(plugin.core.settings, "unknown")
.then(
(result) => {
state.result = result;
state.done = true;
},
(error: unknown) => {
state.error = error instanceof Error ? error.message : String(error);
state.done = true;
}
)
.finally(() => {
manager.onConfirmApplySettingsFromWizard = originalConfirm;
config.getValue = originalGetValue;
});
},
{
stateKey: dialogRunStateKey,
deviceNameKey: SETTING_KEY_P2P_DEVICE_NAME,
deviceName: savedP2PDeviceName,
} }
const originalConfirm = manager.onConfirmApplySettingsFromWizard; );
const state: DialogueRunState = { kind: "p2p-compatibility-settings", done: false };
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
manager.onConfirmApplySettingsFromWizard = async (settings: unknown) => {
state.expected = settings;
return true;
};
void manager
.onSelectServer(plugin.core.settings, "unknown")
.then(
(result) => {
state.result = result;
state.done = true;
},
(error: unknown) => {
state.error = error instanceof Error ? error.message : String(error);
state.done = true;
}
)
.finally(() => {
manager.onConfirmApplySettingsFromWizard = originalConfirm;
});
}, dialogRunStateKey);
}); });
} }
@@ -388,7 +438,7 @@ async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string
if (previousDialogue === null) { if (previousDialogue === null) {
throw new Error("The remote selection dialogue did not expose a close control."); throw new Error("The remote selection dialogue did not expose a close control.");
} }
await modal.locator(".modal-close-button").click({ timeout: uiTimeoutMs }); await modal.locator(".modal-close-button, .modal-header-button").last().click({ timeout: uiTimeoutMs });
await page.waitForFunction((element) => !element.isConnected, previousDialogue, { timeout: uiTimeoutMs }); await page.waitForFunction((element) => !element.isConnected, previousDialogue, { timeout: uiTimeoutMs });
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
} }
@@ -495,9 +545,21 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }), has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }),
}); });
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
const peerName = modal.locator('input[name="p2p-device-peer-id"]');
await peerName.waitFor({ state: "visible", timeout: uiTimeoutMs });
await page.waitForFunction(
(expectedName) => {
const input = document.querySelector('input[name="p2p-device-peer-id"]');
return input instanceof HTMLInputElement && !input.disabled && input.value === expectedName;
},
savedP2PDeviceName,
{ timeout: uiTimeoutMs }
);
if ((await peerName.inputValue()) !== savedP2PDeviceName) {
throw new Error("The saved P2P device name was not loaded before submitting the setup dialogue.");
}
await modal.locator('input[name="p2p-room-id"]').fill("e2e-p2p-compatibility"); await modal.locator('input[name="p2p-room-id"]').fill("e2e-p2p-compatibility");
await modal.locator('input[name="p2p-password"]').fill("e2e-passphrase"); await modal.locator('input[name="p2p-password"]').fill("e2e-passphrase");
await modal.locator('input[name="p2p-device-peer-id"]').fill("e2e-device");
const compatibility = modal.locator("details").filter({ const compatibility = modal.locator("details").filter({
has: page.locator("summary").filter({ hasText: "Connection compatibility" }), has: page.locator("summary").filter({ hasText: "Connection compatibility" }),
@@ -565,8 +627,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
{ timeout: uiTimeoutMs } { timeout: uiTimeoutMs }
); );
const resetNotice = compatibility.locator(".sls-info-note-notice").filter({ const resetNotice = compatibility.locator(".sls-info-note-notice").filter({
hasText: hasText: "TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
}); });
await resetNotice.waitFor({ state: "visible", timeout: uiTimeoutMs }); await resetNotice.waitFor({ state: "visible", timeout: uiTimeoutMs });
await resetNotice await resetNotice
@@ -594,10 +655,16 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
}); });
const state = await assertDialogueRunCompleted(); const state = await assertDialogueRunCompleted();
if (state.deviceNameRead !== true) {
throw new Error("The saved P2P device name was not read through setting.config.");
}
if (state.result !== true || typeof state.expected !== "object" || state.expected === null) { if (state.result !== true || typeof state.expected !== "object" || state.expected === null) {
throw new Error("The P2P compatibility settings were not passed to the setup workflow."); throw new Error("The P2P compatibility settings were not passed to the setup workflow.");
} }
const settings = state.expected as Record<string, unknown>; const settings = state.expected as Record<string, unknown>;
if (settings.P2P_DevicePeerName !== savedP2PDeviceName) {
throw new Error("The saved P2P device name was not passed through the setup workflow.");
}
if (settings.P2P_maxWirePayloadBytes !== 800 || settings.P2P_connectionPath !== "relay") { if (settings.P2P_maxWirePayloadBytes !== 800 || settings.P2P_connectionPath !== "relay") {
throw new Error( throw new Error(
`The P2P compatibility choices did not round-trip through the setup dialogue: ${JSON.stringify({ `The P2P compatibility choices did not round-trip through the setup dialogue: ${JSON.stringify({
@@ -672,15 +739,14 @@ async function verifyGenerateSetupUriDialogue(mode: DialogueMode): Promise<strin
const app = (globalThis as ObsidianTestGlobal).app; const app = (globalThis as ObsidianTestGlobal).app;
return Boolean( return Boolean(
app?.plugins?.plugins["obsidian-livesync"]?.core?.settings.isConfigured && app?.plugins?.plugins["obsidian-livesync"]?.core?.settings.isConfigured &&
app.commands?.commands?.[commandId] app.commands?.commands?.[commandId]
); );
}, },
"obsidian-livesync:livesync-copysetupuri", "obsidian-livesync:livesync-copysetupuri",
{ timeout: uiTimeoutMs } { timeout: uiTimeoutMs }
); );
return await page.evaluate( return await page.evaluate(
(commandId) => (commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
(globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
"obsidian-livesync:livesync-copysetupuri" "obsidian-livesync:livesync-copysetupuri"
); );
}); });
@@ -714,7 +780,8 @@ async function verifyGenerateSetupUriDialogue(mode: DialogueMode): Promise<strin
timeout: uiTimeoutMs, timeout: uiTimeoutMs,
}); });
await choice.getByRole("button", { name: "Time-bound", exact: true }).waitFor({ await choice.getByRole("button", { name: "Time-bound", exact: true }).waitFor({
state: "visible", timeout: uiTimeoutMs, state: "visible",
timeout: uiTimeoutMs,
}); });
if (mode === "mobile") await assertMobileDialogueLayout(page, choice, "Setup URI availability dialogue"); if (mode === "mobile") await assertMobileDialogueLayout(page, choice, "Setup URI availability dialogue");
await choice.getByRole("button", { name: selected, exact: true }).click({ timeout: uiTimeoutMs }); await choice.getByRole("button", { name: selected, exact: true }).click({ timeout: uiTimeoutMs });
@@ -866,6 +933,7 @@ async function verifyCompatibleAlignmentSettingDefault(): Promise<void> {
if (!(await toggle.evaluate((element) => element.classList.contains("is-enabled")))) { if (!(await toggle.evaluate((element) => element.classList.contains("is-enabled")))) {
throw new Error("The automatic compatible-setting policy was displayed as disabled while still undefined."); throw new Error("The automatic compatible-setting policy was displayed as disabled while still undefined.");
} }
await settingsNavigator.close();
}); });
} }
@@ -1094,9 +1162,10 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
); );
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const liveSyncSettings = page.locator(".sls-setting"); const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
const liveSyncSettings = await settingsNavigator.openPage("Hatch");
const logSetting = liveSyncSettings.locator(".setting-item").filter({ const logSetting = liveSyncSettings.locator(".setting-item").filter({
has: page.getByText("Write logs into the file", { exact: true }), has: settingsNavigator.page.getByText("Write logs into the file", { exact: true }),
}); });
await logSetting.locator(".checkbox-container").click({ timeout: uiTimeoutMs }); await logSetting.locator(".checkbox-container").click({ timeout: uiTimeoutMs });
await page.waitForFunction( await page.waitForFunction(
@@ -1130,10 +1199,10 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
// Saving a toggle refreshes the settings pane. Resolve the visible control again so the // Saving a toggle refreshes the settings pane. Resolve the visible control again so the
// second action does not target the detached pre-save element. // second action does not target the detached pre-save element.
const refreshedLogToggle = page const refreshedLogToggle = liveSyncSettings
.locator(".sls-setting:visible .setting-item:visible") .locator(".setting-item:visible")
.filter({ .filter({
has: page.getByText("Write logs into the file", { exact: true }), has: settingsNavigator.page.getByText("Write logs into the file", { exact: true }),
}) })
.locator(".checkbox-container:visible") .locator(".checkbox-container:visible")
.last(); .last();
@@ -1179,13 +1248,9 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
} }
}; };
}, repairRunStateKey); }, repairRunStateKey);
await page await liveSyncSettings.getByRole("button", { name: "Recreate current chunks", exact: true }).click({
.locator(".sls-setting:visible") timeout: uiTimeoutMs,
.last() });
.getByRole("button", { name: "Recreate current chunks", exact: true })
.click({
timeout: uiTimeoutMs,
});
await page.waitForFunction( await page.waitForFunction(
(stateKey) => (stateKey) =>
(globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey]?.done === true, (globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey]?.done === true,
@@ -1200,13 +1265,9 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
throw new Error(`Recreate missing chunks failed: ${repairState.error}`); throw new Error(`Recreate missing chunks failed: ${repairState.error}`);
} }
await page await liveSyncSettings.getByRole("button", { name: "Begin inspection", exact: true }).click({
.locator(".sls-setting:visible") timeout: uiTimeoutMs,
.last() });
.getByRole("button", { name: "Begin inspection", exact: true })
.click({
timeout: uiTimeoutMs,
});
await page await page
.locator(".notice") .locator(".notice")
.filter({ hasText: /^done$/u }) .filter({ hasText: /^done$/u })
@@ -1214,6 +1275,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
state: "visible", state: "visible",
timeout: uiTimeoutMs, timeout: uiTimeoutMs,
}); });
await settingsNavigator.close();
}); });
return screenshotPath; return screenshotPath;
+11 -3
View File
@@ -372,13 +372,21 @@ async function restartAndResumeHarness(): Promise<string> {
await harness await harness
.locator('[data-testid="review-harness-resumed"]') .locator('[data-testid="review-harness-resumed"]')
.waitFor({ state: "visible", timeout: uiTimeoutMs }); .waitFor({ state: "visible", timeout: uiTimeoutMs });
const continuationRemoved = await page.evaluate((stateKey) => { const continuationRemoved = await page.evaluate(async (stateKey) => {
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) { if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) {
throw new Error("Self-hosted LiveSync is unavailable after restart."); throw new Error("Self-hosted LiveSync is unavailable after restart.");
} }
const core = (plugin as { core: { services: { setting: { getSmallConfig(key: string): string } } } }).core; const core = (
return core.services.setting.getSmallConfig(stateKey) === ""; plugin as {
core: {
services: {
setting: { config: { getValue(key: string, defaultValue: string): Promise<string> } };
};
};
}
).core;
return (await core.services.setting.config.getValue(stateKey, "")) === "";
}, REVIEW_HARNESS_STATE_KEY); }, REVIEW_HARNESS_STATE_KEY);
if (!continuationRemoved) throw new Error("The one-shot continuation was not removed before use."); if (!continuationRemoved) throw new Error("The one-shot continuation was not removed before use.");
await assertNoHorizontalOverflow(page, harness, { label: "resumed Review Harness" }); await assertNoHorizontalOverflow(page, harness, { label: "resumed Review Harness" });
+17 -9
View File
@@ -58,8 +58,9 @@ type LiveSyncTestPlugin = {
usePowerUserMode: boolean; usePowerUserMode: boolean;
useEdgeCaseMode: boolean; useEdgeCaseMode: boolean;
hashCacheMaxCount: number; hashCacheMaxCount: number;
customChunkSize: number;
}; };
getSmallConfig(key: string): string | null; config: { getValue(key: string, defaultValue: string): Promise<string> };
}; };
}; };
}; };
@@ -484,12 +485,12 @@ async function verifyCompatibilityReview(): Promise<void> {
}); });
await withObsidianPage(port, async (page) => { await withObsidianPage(port, async (page) => {
const markerBeforeAcknowledgement = await page.evaluate(() => { const markerBeforeAcknowledgement = await page.evaluate(async () => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable"); if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
return plugin.core.services.setting.getSmallConfig("database-compatibility-version"); return await plugin.core.services.setting.config.getValue("database-compatibility-version", "");
}); });
if (markerBeforeAcknowledgement !== null && markerBeforeAcknowledgement !== "") { if (markerBeforeAcknowledgement !== "") {
throw new Error( throw new Error(
`The database version was marked as acknowledged before review: ${markerBeforeAcknowledgement}` `The database version was marked as acknowledged before review: ${markerBeforeAcknowledgement}`
); );
@@ -579,12 +580,12 @@ async function verifyCompatibilityReview(): Promise<void> {
.click(); .click();
await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs }); await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs });
await page.waitForFunction( await page.waitForFunction(
(expectedVersion) => { async (expectedVersion) => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) return false; if (plugin === undefined) return false;
const setting = plugin.core.services.setting; const setting = plugin.core.services.setting;
return ( return (
setting.getSmallConfig("database-compatibility-version") === expectedVersion && (await setting.config.getValue("database-compatibility-version", "")) === expectedVersion &&
setting.currentSettings().versionUpFlash === "" setting.currentSettings().versionUpFlash === ""
); );
}, },
@@ -611,10 +612,16 @@ async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<string> {
state: "visible", state: "visible",
timeout: uiTimeoutMs, timeout: uiTimeoutMs,
}); });
await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({ const chunkSize = await page.evaluate(() => {
state: "visible", const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
timeout: uiTimeoutMs, return plugin?.core.services.setting.currentSettings().customChunkSize;
}); });
if (chunkSize !== 0) {
throw new Error("Compatibility review must retain the fixture's zero chunk-size value.");
}
if ((await doctor.getByText("Enhance chunk size", { exact: true }).count()) !== 0) {
throw new Error("Config Doctor must accept the recommended zero chunk-size value.");
}
if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) { if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) {
throw new Error("Config Doctor still treats supported Data Compression as a problem."); throw new Error("Config Doctor still treats supported Data Compression as a problem.");
} }
@@ -978,6 +985,7 @@ function createSettingsPluginData(settingsOnlyRun: boolean): Record<string, unkn
liveSync: false, liveSync: false,
versionUpFlash: settingsOnlyRun ? "" : compatibilityReviewMessage, versionUpFlash: settingsOnlyRun ? "" : compatibilityReviewMessage,
notifyThresholdOfRemoteStorageSize: 0, notifyThresholdOfRemoteStorageSize: 0,
customChunkSize: 0,
syncOnStart: false, syncOnStart: false,
syncOnSave: false, syncOnSave: false,
syncOnEditorSave: false, syncOnEditorSave: false,
+4
View File
@@ -12,6 +12,10 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
## Unreleased ## Unreleased
### CLI
- Unreadable or malformed device-local state now stops initialisation instead of being treated as empty. Existing valid state files keep their format.
## 1.0.39 ## 1.0.39
20th October, 2026 20th October, 2026
+2 -2
View File
@@ -1,5 +1,5 @@
// Keep CouchDB database-version negotiation isolated from Setup URI generation. // Keep CouchDB database-version negotiation isolated from Setup URI generation.
// The exact release must match utils/livesync-commonlib-version.ts; the setup // The exact release must match utils/livesync-commonlib-version.ts; the setup
// tool suite checks every static specifier before release. // tool suite checks every static specifier before release.
export { checkRemoteVersion } from "npm:@vrtmrz/livesync-commonlib@0.1.34/compat/pouchdb/negotiation"; export { checkRemoteVersion } from "npm:@vrtmrz/livesync-commonlib@0.2.0/compat/pouchdb/negotiation";
export { PouchDB } from "npm:@vrtmrz/livesync-commonlib@0.1.34/compat/pouchdb/pouchdb-browser"; export { PouchDB } from "npm:@vrtmrz/livesync-commonlib@0.2.0/compat/pouchdb/pouchdb-browser";
+50 -7
View File
@@ -1,9 +1,25 @@
{ {
"version": "5", "version": "5",
"specifiers": { "specifiers": {
"npm:@vrtmrz/livesync-commonlib@0.1.34": "0.1.34" "npm:@vrtmrz/livesync-commonlib@0.2.0": "0.2.0"
}, },
"npm": { "npm": {
"@aws-crypto/sha256-js@5.2.0": {
"integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
"dependencies": [
"@aws-crypto/util",
"@aws-sdk/types",
"tslib"
]
},
"@aws-crypto/util@5.2.0": {
"integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
"dependencies": [
"@aws-sdk/types",
"@smithy/util-utf8",
"tslib"
]
},
"@aws-sdk/checksums@3.1000.18": { "@aws-sdk/checksums@3.1000.18": {
"integrity": "sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==", "integrity": "sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==",
"dependencies": [ "dependencies": [
@@ -230,6 +246,12 @@
"tslib" "tslib"
] ]
}, },
"@smithy/is-array-buffer@2.2.0": {
"integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
"dependencies": [
"tslib"
]
},
"@smithy/md5-js@4.4.10": { "@smithy/md5-js@4.4.10": {
"integrity": "sha512-XI5xhWxRkWuiLNj0/Z30vLTPpZe9UQcg57Ox/n4vzGmFiHSrD+xAmx6ubEcWt6u69IOH+4LAucpaZk5AMxB8oQ==", "integrity": "sha512-XI5xhWxRkWuiLNj0/Z30vLTPpZe9UQcg57Ox/n4vzGmFiHSrD+xAmx6ubEcWt6u69IOH+4LAucpaZk5AMxB8oQ==",
"dependencies": [ "dependencies": [
@@ -267,6 +289,13 @@
"tslib" "tslib"
] ]
}, },
"@smithy/util-buffer-from@2.2.0": {
"integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
"dependencies": [
"@smithy/is-array-buffer",
"tslib"
]
},
"@smithy/util-retry@4.5.10": { "@smithy/util-retry@4.5.10": {
"integrity": "sha512-hYu5ieq8myuO29xCQV0IIhicRm1aO0lcNBeMcF1mVVAVQC0oylPGKFliMq5NbxtdOvRxJWCMuir2gwKI91f1lw==", "integrity": "sha512-hYu5ieq8myuO29xCQV0IIhicRm1aO0lcNBeMcF1mVVAVQC0oylPGKFliMq5NbxtdOvRxJWCMuir2gwKI91f1lw==",
"dependencies": [ "dependencies": [
@@ -274,6 +303,13 @@
"tslib" "tslib"
] ]
}, },
"@smithy/util-utf8@2.3.0": {
"integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
"dependencies": [
"@smithy/util-buffer-from",
"tslib"
]
},
"@trystero-p2p/core@0.25.3": { "@trystero-p2p/core@0.25.3": {
"integrity": "sha512-lQKNq/ha+vF6kQZrpaXJGzlzxLF/Fhizoy5dwJUYQlkqRrbPup/Jov2EpPX/CPr2X+f79HxVzonkeni3MxmCuQ==" "integrity": "sha512-lQKNq/ha+vF6kQZrpaXJGzlzxLF/Fhizoy5dwJUYQlkqRrbPup/Jov2EpPX/CPr2X+f79HxVzonkeni3MxmCuQ=="
}, },
@@ -284,9 +320,10 @@
"@trystero-p2p/core" "@trystero-p2p/core"
] ]
}, },
"@vrtmrz/livesync-commonlib@0.1.34": { "@vrtmrz/livesync-commonlib@0.2.0": {
"integrity": "sha512-EeVpeFg3W43cpN+x0BZvFj9fN+eQFil9vohvmLLV1z/8bCTT0Hsq/bfRV59oL0qFNggPetkwyCZUusdfjajNOw==", "integrity": "sha512-K24Pkke+FTjZI7DjIV7qkZk+BmC9VebN3da+2cVD9SWOgoGl7OvDyXTHDafbrmmM4vi4ydaydAEh9glO1xHF6A==",
"dependencies": [ "dependencies": [
"@aws-crypto/sha256-js",
"@aws-sdk/client-s3", "@aws-sdk/client-s3",
"@smithy/fetch-http-handler", "@smithy/fetch-http-handler",
"@smithy/md5-js", "@smithy/md5-js",
@@ -294,6 +331,7 @@
"@smithy/types", "@smithy/types",
"@smithy/util-retry", "@smithy/util-retry",
"@trystero-p2p/nostr", "@trystero-p2p/nostr",
"@vrtmrz/value-store",
"diff-match-patch", "diff-match-patch",
"events", "events",
"fflate", "fflate",
@@ -305,9 +343,11 @@
"pouchdb-adapter-idb", "pouchdb-adapter-idb",
"pouchdb-adapter-indexeddb", "pouchdb-adapter-indexeddb",
"pouchdb-adapter-memory", "pouchdb-adapter-memory",
"pouchdb-checkpointer",
"pouchdb-core", "pouchdb-core",
"pouchdb-errors", "pouchdb-errors",
"pouchdb-find", "pouchdb-find",
"pouchdb-generate-replication-id",
"pouchdb-mapreduce", "pouchdb-mapreduce",
"pouchdb-merge", "pouchdb-merge",
"pouchdb-replication", "pouchdb-replication",
@@ -317,6 +357,9 @@
"xxhash-wasm-102@npm:xxhash-wasm@1.1.0" "xxhash-wasm-102@npm:xxhash-wasm@1.1.0"
] ]
}, },
"@vrtmrz/value-store@0.1.0": {
"integrity": "sha512-M62f1j0bnJkqpgI5sYxnkXUAOSDEwQbYrLf+w58gCCm4sjIXDmFzDJd2duXabRbktBlACt9P99yjN/9gOAPxrA=="
},
"abstract-leveldown@2.7.2": { "abstract-leveldown@2.7.2": {
"integrity": "sha512-+OVvxH2rHVEhWLdbudP6p0+dNMXu8JA1CbhP19T8paTYAcX7oJ4OVjT+ZUVpv7mITxXHqDMej+GdqXBmXkw09w==", "integrity": "sha512-+OVvxH2rHVEhWLdbudP6p0+dNMXu8JA1CbhP19T8paTYAcX7oJ4OVjT+ZUVpv7mITxXHqDMej+GdqXBmXkw09w==",
"dependencies": [ "dependencies": [
@@ -497,8 +540,8 @@
], ],
"deprecated": true "deprecated": true
}, },
"minimatch@10.2.5": { "minimatch@10.2.6": {
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dependencies": [ "dependencies": [
"brace-expansion" "brace-expansion"
] ]
@@ -509,8 +552,8 @@
"whatwg-url" "whatwg-url"
] ]
}, },
"octagonal-wheels@0.1.54": { "octagonal-wheels@0.1.55": {
"integrity": "sha512-Je3ancYhjKX7UY2K19T/qTjG8C9nK8YVrACr5naIf78mN4bbjQkYyWmlj+ooifV/moWVsQrp4fEWz/7mv6It3A==", "integrity": "sha512-mHsQv/h4Tw4ZGJCmTxXFlgeRvPuL9s47C0arD8Njqld+DKr3ppaWjVteEBP+9bWFSOpv/a1u2yY3LYNFGmuexg==",
"dependencies": [ "dependencies": [
"idb" "idb"
] ]
+7 -3
View File
@@ -1,6 +1,7 @@
import { import {
decodeSettingsFromSetupURI, decodeSettingsFromSetupURI,
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
PREFERRED_SETTING_SELF_HOSTED,
} from "../setup/livesync-commonlib.ts"; } from "../setup/livesync-commonlib.ts";
function assert(condition: unknown, message: string): asserts condition { function assert(condition: unknown, message: string): asserts condition {
@@ -38,7 +39,9 @@ Deno.test("generates a current self-hosted Setup URI through the published Commo
const recoveryCode = stdout.match(/sls-id-v1:[0-9a-f]{64}/u)?.[0]; const recoveryCode = stdout.match(/sls-id-v1:[0-9a-f]{64}/u)?.[0];
assert(recoveryCode, "the generator did not print an ID recovery code"); assert(recoveryCode, "the generator did not print an ID recovery code");
assert( assert(
(effectiveSettings as typeof effectiveSettings & { idDerivationKey?: string }).idDerivationKey === (effectiveSettings as typeof effectiveSettings & {
idDerivationKey?: string;
}).idDerivationKey ===
recoveryCode.slice("sls-id-v1:".length), recoveryCode.slice("sls-id-v1:".length),
"the CouchDB Setup URI did not contain the generated ID key", "the CouchDB Setup URI did not contain the generated ID key",
); );
@@ -47,8 +50,9 @@ Deno.test("generates a current self-hosted Setup URI through the published Commo
"the CouchDB Setup URI left the imported device unconfigured", "the CouchDB Setup URI left the imported device unconfigured",
); );
assert( assert(
effectiveSettings.customChunkSize === 60, effectiveSettings.customChunkSize ===
"the Setup URI did not use the current self-hosted chunk-size recommendation", PREFERRED_SETTING_SELF_HOSTED.customChunkSize,
"the Setup URI did not use the self-hosted chunk-size preference from Commonlib",
); );
assert( assert(
effectiveSettings.chunkSplitterVersion === "v3-rabin-karp", effectiveSettings.chunkSplitterVersion === "v3-rabin-karp",
+1 -1
View File
@@ -2,4 +2,4 @@
// Commonlib registry release. Static npm specifiers cannot interpolate this // Commonlib registry release. Static npm specifiers cannot interpolate this
// value, so livesync-commonlib-version.test.ts verifies the domain-specific // value, so livesync-commonlib-version.test.ts verifies the domain-specific
// facades against it. // facades against it.
export const LIVESYNC_COMMONLIB_VERSION = "0.1.34"; export const LIVESYNC_COMMONLIB_VERSION = "0.2.0";
+4 -3
View File
@@ -1,6 +1,7 @@
import { import {
decodeSettingsFromSetupURI, decodeSettingsFromSetupURI,
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
PREFERRED_JOURNAL_SYNC,
} from "./livesync-commonlib.ts"; } from "./livesync-commonlib.ts";
import { generateSetupURI } from "./generate_setup_uri.ts"; import { generateSetupURI } from "./generate_setup_uri.ts";
@@ -55,8 +56,8 @@ Deno.test("generates an Object Storage Setup URI with a selected S3 profile", as
"the Setup URI left the imported device unconfigured", "the Setup URI left the imported device unconfigured",
); );
assert( assert(
effective.customChunkSize === 10, effective.customChunkSize === PREFERRED_JOURNAL_SYNC.customChunkSize,
"the journal chunk-size preset was not applied", "the Object Storage Setup URI did not use its Commonlib journal chunk-size preference",
); );
assert( assert(
effective.liveSync, effective.liveSync,
@@ -122,7 +123,7 @@ Deno.test("generates a random-room P2P Setup URI without copying a device identi
); );
assert( assert(
effective.customChunkSize === 0, effective.customChunkSize === 0,
"the P2P profile inherited the self-hosted CouchDB chunk-size recommendation", "the P2P Setup URI did not retain its zero chunk-size value",
); );
assert( assert(
effective.sendChunksBulkMaxSize === 1, effective.sendChunksBulkMaxSize === 1,
+6 -6
View File
@@ -5,10 +5,10 @@ export {
decodeSettingsFromSetupURI, decodeSettingsFromSetupURI,
encodeTimeBoundSetupURI, encodeTimeBoundSetupURI,
isTimeBoundSetupURIUsableNow, isTimeBoundSetupURIUsableNow,
} from "npm:@vrtmrz/livesync-commonlib@0.1.34/setup-uri"; } from "npm:@vrtmrz/livesync-commonlib@0.2.0/setup-uri";
export type { TimeBoundSetupURIMode } from "npm:@vrtmrz/livesync-commonlib@0.1.34/setup-uri"; export type { TimeBoundSetupURIMode } from "npm:@vrtmrz/livesync-commonlib@0.2.0/setup-uri";
export { generateP2PRoomId } from "npm:@vrtmrz/livesync-commonlib@0.1.34/compat/common/utils"; export { generateP2PRoomId } from "npm:@vrtmrz/livesync-commonlib@0.2.0/compat/common/utils";
export { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.1.34/remote-configurations"; export { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.2.0/remote-configurations";
export { export {
createNewVaultSettings, createNewVaultSettings,
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
@@ -16,5 +16,5 @@ export {
PREFERRED_BASE, PREFERRED_BASE,
PREFERRED_JOURNAL_SYNC, PREFERRED_JOURNAL_SYNC,
PREFERRED_SETTING_SELF_HOSTED, PREFERRED_SETTING_SELF_HOSTED,
} from "npm:@vrtmrz/livesync-commonlib@0.1.34/settings"; } from "npm:@vrtmrz/livesync-commonlib@0.2.0/settings";
export type { ObsidianLiveSyncSettings } from "npm:@vrtmrz/livesync-commonlib@0.1.34/settings"; export type { ObsidianLiveSyncSettings } from "npm:@vrtmrz/livesync-commonlib@0.2.0/settings";