Merge main and refresh translation resources

This commit is contained in:
vorotamoroz committed 2026-10-29 11:28:41 +00:00
commit 037e39c9ff
247 files changed
+23914 -2510

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
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 { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
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> {
API: InjectableAPIService<T>;
appLifecycle: AppLifecycleService<T>;
config: ConfigService<T>;
config: IConfigService;
control: ControlService<T>;
replicator: ReplicatorService<T>;
}
@@ -1,11 +1,10 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { KeyValueDatabaseFactory } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
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 { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
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 { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService";
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 { BrowserConfirm } from "./BrowserConfirm";
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
import {
LiveSyncBrowserAPIService,
type LiveSyncBrowserAPIServiceOptions,
} from "./LiveSyncBrowserAPIService";
import { LiveSyncBrowserAPIService, type LiveSyncBrowserAPIServiceOptions } from "./LiveSyncBrowserAPIService";
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
export interface LiveSyncBrowserSettingsPersistence {
@@ -43,6 +39,7 @@ export interface LiveSyncBrowserRestartPolicy {
}
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
deviceValues: ValueStore & ValueDeleter;
context?: T;
getSystemVaultName?: () => string;
settings?: LiveSyncBrowserSettingsPersistence;
@@ -51,35 +48,12 @@ export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
API?: Omit<LiveSyncBrowserAPIServiceOptions, "confirm" | "getSystemVaultName">;
}
class LiveSyncBrowserAppLifecycleService<
T extends ServiceContext,
> extends InjectableAppLifecycleService<T> {}
class LiveSyncBrowserAppLifecycleService<T extends ServiceContext> extends InjectableAppLifecycleService<T> {}
class LiveSyncBrowserDatabaseService<T extends ServiceContext> extends DatabaseService<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. */
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
@@ -98,17 +72,14 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
const setting = new InjectableSettingService(context, {
APIService: API,
onDisplayLanguageChanged: setLang,
deviceValues: options.deviceValues,
});
const settingsPersistence = options.settings;
setting.loadData.setHandler(
settingsPersistence
? () => settingsPersistence.load()
: () => Promise.resolve(undefined)
settingsPersistence ? () => settingsPersistence.load() : () => Promise.resolve(undefined)
);
setting.saveData.setHandler(
settingsPersistence
? (settings) => settingsPersistence.save(settings)
: () => Promise.resolve()
settingsPersistence ? (settings) => settingsPersistence.save(settings) : () => Promise.resolve()
);
const appLifecycle = new LiveSyncBrowserAppLifecycleService(context, {
@@ -142,7 +113,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
setting,
API,
});
const config = new LiveSyncBrowserConfigService(context, setting);
const config = setting.config;
const replicator = new InjectableReplicatorService(context, {
settingService: setting,
appLifecycleService: appLifecycle,
@@ -163,8 +134,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
databaseService: database,
});
const keyValueDB = new LiveSyncBrowserKeyValueDBService(context, {
openKeyValueDatabase:
options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
openKeyValueDatabase: options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
appLifecycle,
databaseEvents,
vault,
@@ -209,7 +179,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
}
export function createLiveSyncBrowserServiceHub<T extends ServiceContext>(
options: LiveSyncBrowserServiceHubOptions<T> = {}
options: LiveSyncBrowserServiceHubOptions<T>
): LiveSyncBrowserServiceHub<T> {
return new LiveSyncBrowserServiceHub(options);
}
@@ -1,5 +1,6 @@
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
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 {
@@ -21,7 +22,8 @@ describe("LiveSync browser service context contract", () => {
const context = createServiceContext({
translate: (key) => `webapp:${key}`,
});
const hub = createLiveSyncBrowserServiceHub({ context });
const deviceValues = new MemoryValueStore();
const hub = createLiveSyncBrowserServiceHub({ context, deviceValues });
expect(observeServiceContext(context, "moduleLocalDatabase.logWaitingForReady")).toEqual({
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(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 () => {
const deviceLocalSettings = new Map<string, string>();
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 deviceValues = new MemoryValueStore();
const savedSettings: (typeof DEFAULT_SETTINGS)[] = [];
const restartCalls: string[] = [];
const nativeFetch = vi.fn(async () => new Response("ok"));
const options: LiveSyncBrowserServiceHubOptions<ReturnType<typeof createServiceContext>> = {
deviceValues,
getSystemVaultName: () => "browser-vault",
settings: {
load: async () => ({
@@ -69,6 +68,8 @@ describe("LiveSync browser service context contract", () => {
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.getPlatform()).toBe("browser");
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(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");
});
});
+47 -5
View File
@@ -64,9 +64,10 @@ livesync-cli [database-path] [command] [args...]
- `database-path`: Path to the directory where `.livesync` folder and `settings.json` are (or will be) located.
- Note: In previous versions, this was referred to as the "vault" path. Now it is clearly distinguished from the actual vault (the directory containing your `.md` files).
- `--vault <path>` / `-V <path>`: (daemon/mirror only) Path to the vault directory containing `.md` files.
- `--vault <path>` / `-V <path>`: (daemon/mirror/rebuild/fetch) Path to the Vault directory containing `.md` files.
- Allows the PouchDB database directory and the actual vault directory to be different locations.
- For `mirror` command, the positional `[vault-path]` argument takes precedence over `--vault`.
- `--force` / `-f`: Required by `rebuild` and `fetch` because they replace a database. It also retains its existing `init-settings` overwrite behaviour.
- `--write-settings`: Write setting migrations and other lasting changes after the command succeeds.
- `init-settings` writes its target file. `setup`, `remote-add`, `remote-rm`, `remote-set`, and `remote-activate` write their settings changes without this option.
- All remaining commands leave the settings file unchanged by default.
@@ -82,6 +83,9 @@ livesync-cli [database-path] [command] [args...]
- If `vault-path` is provided, the CLI will synchronise the database with files in the vault directory.
- If `vault-path` is omitted, it defaults to `database-path` (compatibility mode).
- Use this command to keep your local `.md` files in sync with the database.
- `rebuild`: Rebuild the local and remote databases from the selected Vault. This replaces existing local and remote database state and requires `--force`.
- This command may select a new local database suffix. Add `--write-settings` if later CLI commands should use the resulting database selection; otherwise the existing settings file remains unchanged.
- `fetch`: Replace the local database with the configured remote state, then reflect fetched files to the selected Vault. Existing local database state is discarded, so this command requires `--force`.
- `ls [prefix]`: List files currently stored in the local database.
- `push <src> <dst>`: Push a local file `<src>` into the database at path `<dst>`.
- `pull <src> <dst>`: Pull a file `<src>` from the database into local file `<dst>`.
@@ -110,6 +114,12 @@ livesync-cli ./my-db sync
# Mirroring to your actual Obsidian vault
livesync-cli ./my-db mirror /path/to/obsidian-vault
# Rebuild local and remote databases from an authoritative Vault
livesync-cli ./my-db --vault /path/to/obsidian-vault rebuild --force --write-settings
# Replace the local database and Vault files from the configured remote
livesync-cli ./my-db --vault /path/to/obsidian-vault fetch --force --write-settings
# Manual file operations
livesync-cli ./my-db push ./note.md folder/note.md
livesync-cli ./my-db pull folder/note.md ./note.md
@@ -369,6 +379,36 @@ The CLI uses the same settings format as the Obsidian plug-in. Create a `.livesy
- `couchDB_DBNAME`: Database name
- `isConfigured`: Set to `true` after configuration
#### Unicode path identity
The `pathNormalizationVersion` setting controls how file paths identify
Metadata documents when using CouchDB. An absent value or `0` keeps the legacy
identity. Version `1` uses Unicode Normalization Form C (NFC) for Metadata IDs
and path comparison keys. It does not rewrite `Metadata.path`, rename existing
files, or change Chunks or file content. `handleFilenameCaseSensitive` remains
an independent case-handling setting; the CLI resolves NFC-equivalent names
even when case-sensitive handling is enabled.
When the CLI resolves an existing path, it uses the actual spelling of each
matching file or directory for reads, writes, deletes, `stat` calls, directory
creation, and renames. If a path component is missing, it uses the actual
existing parent and preserves the spelling received for the new component. If
a file or any parent directory has multiple candidates for the same logical
key after NFC and the existing case rule, the CLI warns and refuses the
operation. It does not choose a candidate based on directory order, merge
entries, or rename them.
Version `1` requires a coordinated Rebuild and Fetch; changing the setting by
itself does not migrate a database. Update every participating client, stop
synchronisation, and back up a complete authoritative Vault. Set
`pathNormalizationVersion` to `1` on the source client, then run `rebuild`
with `--force --write-settings`. Set the same value on each remaining CLI
client, then run `fetch` with `--force --write-settings`. Use **Overwrite
Server Data with This Device's Files** and **Reset Synchronisation on This
Device** for the corresponding Obsidian Rebuild and Fetch operations. Resolve
any ambiguous names before reconstruction. Version `1` is supported with
CouchDB only; do not resume a legacy client against the rebuilt database.
### Command-line Reference
```
@@ -381,11 +421,11 @@ Arguments:
Options:
--settings, -s <path> Path to settings file (default: .livesync/settings.json in local database directory)
--force, -f Overwrite existing file on init-settings
--force, -f Required by rebuild/fetch; overwrite existing file on init-settings
--verbose, -v Enable verbose logging
--debug, -d Enable debug logging (includes verbose)
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
--vault <path>, -V <path> (daemon/mirror) Path to vault directory, decoupled from database-path
--vault <path>, -V <path> (daemon/mirror/rebuild/fetch) Path to vault directory, decoupled from database-path
--write-settings Write setting changes after a successful command
--compat-remote-admin-exit-zero
Preserve the former zero exit code when remote-administration verification fails
@@ -398,6 +438,8 @@ Commands:
p2p-peers <timeout> Show discovered peers as [peer]<TAB><peer-id><TAB><peer-name>
p2p-sync <peer> <timeout> Synchronise with specified peer-id or peer-name
p2p-host Start P2P host mode and wait until interrupted (Ctrl+C)
rebuild Rebuild local and remote databases from the selected Vault (requires --force)
fetch Replace the local database with remote state and reflect it to the Vault (requires --force)
push <src> <dst> Push local file <src> into local database path <dst>
pull <src> <dst> Pull file <src> from local database into local file <dst>
pull-rev <src> <dst> <rev> Pull specific revision <rev> into local file <dst>
@@ -677,8 +719,8 @@ src/apps/cli/
│ └── ServiceFileAccessImpl.ts
├── services/
│ ├── NodeKeyValueDBService.ts
│ ├── NodeServiceHub.ts
│ └── NodeSettingService.ts
│ ├── NodeLocalStorage.ts
│ └── NodeServiceHub.ts
├── test/
│ ├── test-e2e-two-vaults-common.sh
│ ├── test-e2e-two-vaults-matrix.sh
+56 -15
View File
@@ -3,7 +3,7 @@ import type { IFileSystemAdapter } from "@vrtmrz/livesync-commonlib/compat/servi
import { NodePathAdapter } from "./NodePathAdapter";
import { NodeTypeGuardAdapter } from "./NodeTypeGuardAdapter";
import { NodeConversionAdapter } from "./NodeConversionAdapter";
import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
import { AmbiguousNormalisedPathError, UnicodeResolvingNodeStorageAdapter } from "./UnicodeResolvingNodeStorageAdapter";
import { NodeVaultAdapter } from "./NodeVaultAdapter";
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
import { path } from "@vrtmrz/livesync-commonlib/node";
@@ -16,19 +16,21 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
readonly path: NodePathAdapter;
readonly typeGuard: NodeTypeGuardAdapter;
readonly conversion: NodeConversionAdapter;
readonly storage: NodeStorageAdapter;
readonly storage: UnicodeResolvingNodeStorageAdapter;
readonly vault: NodeVaultAdapter;
private fileCache = new Map<string, NodeFile>();
constructor(
private basePath: string,
private reportDiagnostic: CliDiagnosticReporter = () => undefined
private reportDiagnostic: CliDiagnosticReporter = () => undefined,
getPathNormalizationVersion: () => number = () => 0,
isCaseInsensitive: () => boolean = () => false
) {
this.path = new NodePathAdapter();
this.typeGuard = new NodeTypeGuardAdapter();
this.conversion = new NodeConversionAdapter();
this.storage = new NodeStorageAdapter(basePath);
this.storage = new UnicodeResolvingNodeStorageAdapter(basePath, getPathNormalizationVersion, isCaseInsensitive);
this.vault = new NodeVaultAdapter(this.storage);
}
@@ -58,6 +60,14 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
async getAbstractFileByPath(p: FilePath | string): Promise<NodeFile | null> {
const pathStr = this.normalisePath(p);
if (this.storage.isUnicodePathNormalizationEnabled) {
const resolvedPath = await this.storage.resolveLogicalPath(pathStr, false, false);
if (resolvedPath === null) {
this.fileCache.delete(pathStr);
return null;
}
return await this.refreshResolvedFile(resolvedPath);
}
if (!this.fileCache.has(pathStr) && !(await this.hasExactPathCase(pathStr))) {
this.fileCache.delete(pathStr);
return null;
@@ -68,6 +78,15 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<NodeFile | null> {
const pathStr = this.normalisePath(p);
if (this.storage.isUnicodePathNormalizationEnabled) {
const resolvedPath = await this.storage.resolveLogicalPath(pathStr, true, false);
if (resolvedPath === null) {
this.fileCache.delete(pathStr);
return null;
}
return await this.refreshResolvedFile(resolvedPath);
}
const exact = await this.getAbstractFileByPath(pathStr);
if (exact) {
return exact;
@@ -115,17 +134,34 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
// This is used by Obsidian to sync internal file metadata
}
async refreshFile(p: string): Promise<NodeFile | null> {
async refreshFile(p: string, caseInsensitive = false): Promise<NodeFile | null> {
const pathStr = this.normalisePath(p);
try {
const stat = await this.storage.stat(pathStr);
if (stat?.type !== "file") {
const resolvedPath = this.storage.isUnicodePathNormalizationEnabled
? await this.storage.resolveLogicalPath(pathStr, caseInsensitive, false)
: pathStr;
if (resolvedPath === null) {
this.fileCache.delete(pathStr);
return null;
}
return await this.refreshResolvedFile(resolvedPath);
} catch (error) {
if (error instanceof AmbiguousNormalisedPathError) throw error;
this.fileCache.delete(pathStr);
return null;
}
}
private async refreshResolvedFile(resolvedPath: string): Promise<NodeFile | null> {
try {
const stat = await this.storage.statExact(resolvedPath);
if (stat?.type !== "file") {
this.fileCache.delete(resolvedPath);
return null;
}
const file: NodeFile = {
path: pathStr as FilePath,
path: resolvedPath as FilePath,
stat: {
size: stat.size,
mtime: stat.mtime,
@@ -133,11 +169,12 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
type: "file",
},
};
this.fileCache.set(pathStr, file);
this.fileCache.set(resolvedPath, file);
return file;
} catch {
} catch (error) {
if (error instanceof AmbiguousNormalisedPathError) throw error;
// Evict so a deleted file is not returned by subsequent cache scans.
this.fileCache.delete(pathStr);
this.fileCache.delete(resolvedPath);
return null;
}
}
@@ -146,18 +183,21 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
* Helper method to recursively scan directory and populate file cache
*/
async scanDirectory(relativePath: string = ""): Promise<void> {
await this.scanDirectoryInto(relativePath, this.fileCache);
const resolvedPath = await this.storage.resolveLogicalPath(relativePath, false, false);
if (resolvedPath !== null) await this.scanDirectoryInto(resolvedPath, this.fileCache);
}
private async scanDirectoryInto(relativePath: string, files: Map<string, NodeFile>): Promise<void> {
const fullPath = this.resolvePath(relativePath);
try {
const directoryStat = await this.storage.stat(relativePath);
const directoryStat = this.storage.isUnicodePathNormalizationEnabled
? await this.storage.statExact(relativePath)
: await this.storage.stat(relativePath, false);
if (directoryStat?.type !== "folder") throw new Error(`Directory does not exist: ${fullPath}`);
const entries = await this.storage.list(relativePath);
const entries = await this.storage.listExact(relativePath);
for (const entryPath of entries.files) {
const stat = await this.storage.stat(entryPath);
const stat = await this.storage.statExact(entryPath);
if (stat?.type !== "file") continue;
const file: NodeFile = {
path: entryPath as FilePath,
@@ -169,6 +209,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
await this.scanDirectoryInto(entryPath, files);
}
} catch (error) {
if (error instanceof AmbiguousNormalisedPathError) throw error;
// Directory doesn't exist or is not readable
this.reportDiagnostic(`Error scanning directory ${fullPath}:`, error);
}
@@ -1,5 +1,12 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import { fsPromises as fs, NodeStorageAdapter, os, path } from "@vrtmrz/livesync-commonlib/node";
import { DEFAULT_SETTINGS, type FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
ServiceRebuilder,
type ServiceRebuilderDependencies,
} from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
describe("NodeFileSystemAdapter file enumeration", () => {
@@ -116,3 +123,382 @@ describe("NodeFileSystemAdapter file enumeration", () => {
await expect(adapter.getFiles()).resolves.toEqual([]);
});
});
describe("NodeFileSystemAdapter Unicode path resolution", () => {
const tempDirs: string[] = [];
async function createVault() {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-cli-unicode-"));
tempDirs.push(directory);
return directory;
}
function createNormalisingAdapter(directory: string, caseInsensitive = false) {
return new NodeFileSystemAdapter(
directory,
undefined,
() => 1,
() => caseInsensitive
);
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
});
it.each([0, 1])("keeps an NFD-only file in the storage listing in identity mode %s", async (version) => {
const directory = await createVault();
const originalPath = "café.md".normalize("NFD");
await fs.writeFile(path.join(directory, originalPath), "original");
const report = vi.fn();
const adapter = new NodeFileSystemAdapter(directory, report, () => version);
expect((await adapter.getFiles()).map((file) => file.path)).toEqual([originalPath]);
expect(report).not.toHaveBeenCalled();
await expect(fs.readFile(path.join(directory, originalPath), "utf8")).resolves.toBe("original");
expect(await fs.readdir(directory)).toEqual([originalPath]);
});
it.each([false, true])(
"enumerates nested mixed names once per directory with case-insensitive matching %s",
async (caseInsensitive) => {
const directory = await createVault();
const parent = "café".normalize("NFD");
const child = `${parent}/Étage`;
await fs.mkdir(path.join(directory, child), { recursive: true });
const names = Array.from(
{ length: 12 },
(_, index) =>
`${child}/${index}-résumé.md`
.normalize(index % 2 ? "NFC" : "NFD")
.split("/")
.pop()!
);
for (const name of names) await fs.writeFile(path.join(directory, child, name), "content");
const adapter = createNormalisingAdapter(directory, caseInsensitive);
const list = vi.spyOn(NodeStorageAdapter.prototype, "list");
try {
const files = await adapter.getFiles();
expect(files.map((file) => file.path).sort()).toEqual(names.map((name) => `${child}/${name}`).sort());
expect(list).toHaveBeenCalledTimes(3);
} finally {
list.mockRestore();
}
}
);
it("uses the existing mixed parent during directory preparation and preserves the new leaf spelling", async () => {
const directory = await createVault();
const parent = "café".normalize("NFD");
const child = `${parent}/Étage`;
await fs.mkdir(path.join(directory, child), { recursive: true });
const adapter = createNormalisingAdapter(directory);
await adapter.storage.mkdir("café");
await adapter.storage.mkdir("café/Étage");
const newName = "résumé.md".normalize("NFD");
const file = await adapter.vault.create(`café/Étage/${newName}`, "created");
expect(file.path).toBe(`${child}/${newName}`);
expect(await fs.readdir(directory)).toEqual([parent]);
expect(await fs.readdir(path.join(directory, parent))).toEqual(["Étage"]);
expect(await fs.readdir(path.join(directory, child))).toEqual([newName]);
await expect(adapter.storage.stat(`café/Étage/${newName.normalize("NFC")}`)).resolves.toMatchObject({
size: 7,
});
});
it("rejects an NFC-equivalent file and directory before scanning or writing a child", async () => {
const directory = await createVault();
const composed = "café";
const decomposed = composed.normalize("NFD");
await fs.writeFile(path.join(directory, composed), "file");
await fs.mkdir(path.join(directory, decomposed));
const adapter = createNormalisingAdapter(directory);
await expect(adapter.getFiles()).rejects.toThrow(/ambiguous/i);
await expect(adapter.storage.write(`${composed}/new.md`, "new")).rejects.toThrow(/ambiguous/i);
await expect(fs.readFile(path.join(directory, composed), "utf8")).resolves.toBe("file");
expect(await fs.readdir(path.join(directory, decomposed))).toEqual([]);
});
it.each([false, true])(
"enumerates each directory once for a flat NFC scan with case-insensitive matching %s",
async (caseInsensitive) => {
const directory = await createVault();
for (let index = 0; index < 12; index++) {
await fs.writeFile(path.join(directory, `${index}.md`), "content");
}
const adapter = createNormalisingAdapter(directory, caseInsensitive);
const list = vi.spyOn(NodeStorageAdapter.prototype, "list");
try {
expect(await adapter.getFiles()).toHaveLength(12);
expect(list).toHaveBeenCalledTimes(1);
} finally {
list.mockRestore();
}
}
);
it("reads, updates, and deletes an NFD file through its NFC path", async () => {
const directory = await createVault();
const nfcFolder = "café";
const nfdFolder = nfcFolder.normalize("NFD");
const nfcName = "Ångström.md";
const nfdName = nfcName.normalize("NFD");
await fs.mkdir(path.join(directory, nfdFolder));
await fs.writeFile(path.join(directory, nfdFolder, nfdName), "before", "utf8");
const adapter = createNormalisingAdapter(directory);
const file = await adapter.getAbstractFileByPath(`${nfcFolder}/${nfcName}`);
expect(file?.path).toBe(`${nfdFolder}/${nfdName}`);
if (!file) throw new Error("Expected the NFC path to resolve to the NFD file");
await expect(adapter.vault.read(file)).resolves.toBe("before");
await adapter.vault.modify(file, "after");
await expect(fs.readFile(path.join(directory, nfdFolder, nfdName), "utf8")).resolves.toBe("after");
expect(await fs.readdir(directory)).toEqual([nfdFolder]);
expect(await fs.readdir(path.join(directory, nfdFolder))).toEqual([nfdName]);
await adapter.vault.delete(file);
await expect(fs.readdir(path.join(directory, nfdFolder))).resolves.toEqual([]);
});
it("creates a new file inside an existing NFD directory using that directory spelling", async () => {
const directory = await createVault();
const nfdFolder = "café".normalize("NFD");
await fs.mkdir(path.join(directory, nfdFolder));
const adapter = createNormalisingAdapter(directory);
const file = await adapter.vault.create("café/new.md", "content");
expect(file.path).toBe(`${nfdFolder}/new.md`);
await expect(fs.readFile(path.join(directory, nfdFolder, "new.md"), "utf8")).resolves.toBe("content");
expect(await fs.readdir(directory)).toEqual([nfdFolder]);
});
it("renames a file by case inside an NFD directory while preserving that directory spelling", async () => {
const directory = await createVault();
const nfcFolder = "Caf\u00e9";
const nfdFolder = nfcFolder.normalize("NFD");
await fs.mkdir(path.join(directory, nfdFolder));
await fs.writeFile(path.join(directory, nfdFolder, "Note.md"), "content", "utf8");
const adapter = createNormalisingAdapter(directory, true);
const file = await adapter.getAbstractFileByPathInsensitive(`${nfcFolder}/Note.md`);
if (!file) throw new Error("Expected the NFC path to resolve to the NFD file");
const renamedFile = await adapter.renameFile(file, `${nfcFolder}/note.md`);
expect(renamedFile.path).toBe(`${nfdFolder}/note.md`);
expect(await fs.readdir(directory)).toEqual([nfdFolder]);
await expect(fs.readdir(path.join(directory, nfdFolder))).resolves.toEqual(["note.md"]);
await expect(fs.readFile(path.join(directory, nfdFolder, "note.md"), "utf8")).resolves.toBe("content");
});
it("rejects an NFC/NFD file collision for lookup and vault mutations", async () => {
const directory = await createVault();
const nfcName = "café.md";
const nfdName = nfcName.normalize("NFD");
await fs.writeFile(path.join(directory, nfcName), "NFC", "utf8");
await fs.writeFile(path.join(directory, nfdName), "NFD", "utf8");
const adapter = createNormalisingAdapter(directory);
const exactFile = {
path: nfcName as FilePath,
stat: { ctime: 1, mtime: 2, size: 3, type: "file" as const },
};
await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toThrow(/ambiguous/i);
await expect(adapter.vault.modify(exactFile, "changed")).rejects.toThrow(/ambiguous/i);
await expect(adapter.vault.delete(exactFile)).rejects.toThrow(/ambiguous/i);
await expect(fs.readFile(path.join(directory, nfcName), "utf8")).resolves.toBe("NFC");
await expect(fs.readFile(path.join(directory, nfdName), "utf8")).resolves.toBe("NFD");
expect((await fs.readdir(directory)).sort()).toEqual([nfcName, nfdName].sort());
});
it("rejects an NFC/NFD directory collision before creating a child", async () => {
const directory = await createVault();
const nfcFolder = "café";
const nfdFolder = nfcFolder.normalize("NFD");
await fs.mkdir(path.join(directory, nfcFolder));
await fs.mkdir(path.join(directory, nfdFolder));
await fs.writeFile(path.join(directory, nfcFolder, "inside.md"), "NFC", "utf8");
await fs.writeFile(path.join(directory, nfdFolder, "inside.md"), "NFD", "utf8");
const adapter = createNormalisingAdapter(directory);
await expect(adapter.getAbstractFileByPath(`${nfcFolder}/inside.md`)).rejects.toThrow(/ambiguous/i);
await expect(adapter.storage.write(`${nfcFolder}/created.md`, "created")).rejects.toThrow(/ambiguous/i);
await expect(fs.readdir(path.join(directory, nfcFolder))).resolves.toEqual(["inside.md"]);
await expect(fs.readdir(path.join(directory, nfdFolder))).resolves.toEqual(["inside.md"]);
await expect(fs.readFile(path.join(directory, nfcFolder, "inside.md"), "utf8")).resolves.toBe("NFC");
await expect(fs.readFile(path.join(directory, nfdFolder, "inside.md"), "utf8")).resolves.toBe("NFD");
});
it("rejects enumeration when NFC-equivalent directories contain different files", async () => {
const directory = await createVault();
const nfcFolder = "café";
const nfdFolder = nfcFolder.normalize("NFD");
await fs.mkdir(path.join(directory, nfcFolder));
await fs.mkdir(path.join(directory, nfdFolder));
await fs.writeFile(path.join(directory, nfcFolder, "a.md"), "NFC", "utf8");
await fs.writeFile(path.join(directory, nfdFolder, "b.md"), "NFD", "utf8");
const adapter = createNormalisingAdapter(directory);
await expect(adapter.getFiles()).rejects.toThrow(/ambiguous/i);
await expect(adapter.scanDirectory()).rejects.toThrow(/ambiguous/i);
await expect(fs.readFile(path.join(directory, nfcFolder, "a.md"), "utf8")).resolves.toBe("NFC");
await expect(fs.readFile(path.join(directory, nfdFolder, "b.md"), "utf8")).resolves.toBe("NFD");
});
it("rechecks the directory after a unique file is cached", async () => {
const directory = await createVault();
const nfcName = "café.md";
const nfdName = nfcName.normalize("NFD");
await fs.writeFile(path.join(directory, nfdName), "NFD", "utf8");
const adapter = createNormalisingAdapter(directory);
await expect(adapter.getAbstractFileByPath(nfcName)).resolves.toMatchObject({ path: nfdName });
await fs.writeFile(path.join(directory, nfcName), "NFC", "utf8");
await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toThrow(/ambiguous/i);
await expect(fs.readFile(path.join(directory, nfcName), "utf8")).resolves.toBe("NFC");
await expect(fs.readFile(path.join(directory, nfdName), "utf8")).resolves.toBe("NFD");
});
it.each([
["Foo", "foo"],
["Café", "café".normalize("NFD")],
])("rejects case-insensitive parent aliases %s and %s containing different files", async (first, second) => {
const directory = await createVault();
for (const [parent, name] of [
[first, "one.md"],
[second, "two.md"],
]) {
await fs.mkdir(path.join(directory, parent));
await fs.writeFile(path.join(directory, parent, name), name);
}
const adapter = createNormalisingAdapter(directory, true);
await expect(adapter.getFiles()).rejects.toThrow(/ambiguous/i);
await expect(adapter.scanDirectory()).rejects.toThrow(/ambiguous/i);
await expect(fs.readFile(path.join(directory, first, "one.md"), "utf8")).resolves.toBe("one.md");
await expect(fs.readFile(path.join(directory, second, "two.md"), "utf8")).resolves.toBe("two.md");
});
it.each([
{ version: 0, caseInsensitive: true },
{ version: 1, caseInsensitive: false },
])(
"compatibility: retains distinct case paths in mode $version with case-insensitive matching $caseInsensitive",
async ({ version, caseInsensitive }) => {
const directory = await createVault();
const names = ["Café/one.md", "café/two.md".normalize("NFD")];
for (const name of names) {
await fs.mkdir(path.dirname(path.join(directory, name)));
await fs.writeFile(path.join(directory, name), name);
}
const adapter = new NodeFileSystemAdapter(
directory,
undefined,
() => version,
() => caseInsensitive
);
expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual([...names].sort());
for (const name of names) await expect(adapter.storage.read(name)).resolves.toBe(name);
}
);
it("refuses reconstruction before starting remote activity when parent aliases are ambiguous", async () => {
const directory = await createVault();
for (const name of ["Café/one.md", "café/two.md".normalize("NFD")]) {
await fs.mkdir(path.dirname(path.join(directory, name)));
await fs.writeFile(path.join(directory, name), name);
}
const adapter = createNormalisingAdapter(directory, true);
const settings = {
...DEFAULT_SETTINGS,
pathNormalizationVersion: 1 as const,
handleFilenameCaseSensitive: false,
};
const setting = { currentSettings: () => settings, suspendAllSync: { addHandler: vi.fn() } };
const runBoundedRemoteActivity = vi.fn(async () => undefined);
const rebuilder = new ServiceRebuilder({
setting,
storageAccess: { getFileNames: async () => (await adapter.getFiles()).map((file) => file.path) },
database: {
onDatabaseReset: { addHandler: vi.fn() },
isDatabaseReady: () => true,
localDatabase: { findAllDocs: async function* () {} },
},
vault: { isTargetFile: async () => true },
path: new PathServiceCompat(new ServiceContext(), { settingService: setting as never }),
replicator: { runBoundedRemoteActivity },
UI: { showMarkdownDialog: vi.fn(async () => undefined) },
} as unknown as ServiceRebuilderDependencies);
await expect(rebuilder.$rebuildEverything()).rejects.toThrow(/ambiguous/i);
expect(runBoundedRemoteActivity).not.toHaveBeenCalled();
});
it("uses fresh listings after external additions, renames, deletions, and collisions", async () => {
const directory = await createVault();
const nfdName = "café.md".normalize("NFD");
await fs.writeFile(path.join(directory, nfdName), "original");
const adapter = createNormalisingAdapter(directory);
expect((await adapter.getFiles()).map((file) => file.path)).toEqual([nfdName]);
const renamed = "résumé.md".normalize("NFD");
await fs.rename(path.join(directory, nfdName), path.join(directory, renamed));
await fs.writeFile(path.join(directory, "new.md"), "new");
expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(["new.md", renamed]);
await fs.rm(path.join(directory, "new.md"));
expect((await adapter.getFiles()).map((file) => file.path)).toEqual([renamed]);
await fs.writeFile(path.join(directory, renamed.normalize("NFC")), "collision");
await expect(adapter.getFiles()).rejects.toThrow(/ambiguous/i);
});
it("retains the storage boundary for exact metadata reads and directory scans", async () => {
const directory = await createVault();
const outside = await createVault();
await fs.writeFile(path.join(outside, "note.md"), "outside");
await fs.symlink(outside, path.join(directory, "linked"), "dir");
const adapter = createNormalisingAdapter(directory);
await expect(adapter.getFiles()).resolves.toEqual([]);
await expect(adapter.storage.statExact("linked/note.md")).rejects.toThrow(/symbolic links/i);
await expect(adapter.storage.write("linked/note.md", "changed")).rejects.toThrow(/symbolic links/i);
await expect(fs.readFile(path.join(outside, "note.md"), "utf8")).resolves.toBe("outside");
});
it("detects NFC and case collisions when lookup is case-insensitive", async () => {
const directory = await createVault();
const upperName = "Café.md";
const lowerNfdName = "café.md".normalize("NFD");
await fs.writeFile(path.join(directory, upperName), "upper", "utf8");
await fs.writeFile(path.join(directory, lowerNfdName), "lower", "utf8");
const adapter = createNormalisingAdapter(directory, true);
await expect(adapter.getAbstractFileByPathInsensitive("CAFÉ.md")).rejects.toThrow(/ambiguous/i);
await expect(adapter.getFiles()).rejects.toThrow(/ambiguous/i);
await expect(adapter.scanDirectory()).rejects.toThrow(/ambiguous/i);
await expect(fs.readFile(path.join(directory, upperName), "utf8")).resolves.toBe("upper");
await expect(fs.readFile(path.join(directory, lowerNfdName), "utf8")).resolves.toBe("lower");
});
it("retains exact-path behaviour when path normalisation is disabled", async () => {
const directory = await createVault();
const nfcName = "café.md";
const nfdName = nfcName.normalize("NFD");
await fs.writeFile(path.join(directory, nfdName), "NFD", "utf8");
const adapter = new NodeFileSystemAdapter(directory);
await expect(adapter.getAbstractFileByPath(nfcName)).resolves.toBeNull();
await adapter.storage.write(nfcName, "NFC");
await expect(fs.readFile(path.join(directory, nfcName), "utf8")).resolves.toBe("NFC");
await expect(fs.readFile(path.join(directory, nfdName), "utf8")).resolves.toBe("NFD");
});
});
+11 -2
View File
@@ -2,6 +2,7 @@ import type { FilePath, UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/co
import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
import type { NodeFile, NodeFolder } from "./NodeTypes";
import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
import { UnicodeResolvingNodeStorageAdapter } from "./UnicodeResolvingNodeStorageAdapter";
/**
* Vault adapter implementation for Node.js
@@ -73,8 +74,16 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
}
private async toNodeFile(path: string): Promise<NodeFile> {
const stat = await this.storage.stat(path);
const resolvedPath =
this.storage instanceof UnicodeResolvingNodeStorageAdapter
? await this.storage.resolveLogicalPath(path, undefined, false)
: path;
if (resolvedPath === null) throw new Error(`Could not read created file metadata: ${path}`);
const stat =
this.storage instanceof UnicodeResolvingNodeStorageAdapter
? await this.storage.statExact(resolvedPath)
: await this.storage.stat(resolvedPath);
if (stat?.type !== "file") throw new Error(`Could not read created file metadata: ${path}`);
return { path: path as FilePath, stat };
return { path: resolvedPath as FilePath, stat };
}
}
@@ -0,0 +1,205 @@
import { NodeStorageAdapter, validateStoragePath } from "@vrtmrz/livesync-commonlib/node";
export class AmbiguousNormalisedPathError extends Error {
constructor(path: string) {
super(`Ambiguous path after Unicode normalisation: ${path}`);
this.name = "AmbiguousNormalisedPathError";
}
}
/** Resolves NFC-equivalent logical paths against current Node storage listings. */
export class UnicodeResolvingNodeStorageAdapter extends NodeStorageAdapter {
constructor(
rootPath: string,
private readonly getPathNormalizationVersion: () => number = () => 0,
private readonly isCaseInsensitive: () => boolean = () => false
) {
super(rootPath);
}
get isUnicodePathNormalizationEnabled(): boolean {
return this.getPathNormalizationVersion() === 1;
}
/** Read metadata for an entry already resolved or obtained from a storage listing. */
async statExact(path: string) {
return await super.trystat(path);
}
/** Inspect a physical directory once before descending into its enumerated entries. */
async listExact(path: string) {
const entries = await super.list(path);
if (this.isUnicodePathNormalizationEnabled) {
const caseInsensitive = this.isCaseInsensitive();
const names = new Map<string, string>();
for (const entry of [...entries.files, ...entries.folders]) {
const key = this.comparisonKey(entry, caseInsensitive);
if (names.has(key)) throw new AmbiguousNormalisedPathError(entry);
names.set(key, entry);
}
}
return entries;
}
async resolveLogicalPath(
path: string,
caseInsensitive = this.isCaseInsensitive(),
allowMissing = true
): Promise<string | null> {
if (!this.isUnicodePathNormalizationEnabled) return path;
validateStoragePath(path, true);
if (path === "") return "";
const segments = path.split("/");
let resolvedParent = "";
for (let index = 0; index < segments.length; index++) {
const segment = segments[index];
const entries = await super.list(resolvedParent);
const candidates = [...entries.files, ...entries.folders];
const requestedKey = this.comparisonKey(segment, caseInsensitive);
const matches = candidates.filter((entryPath) => {
const entryName = entryPath.slice(resolvedParent === "" ? 0 : resolvedParent.length + 1);
return this.comparisonKey(entryName, caseInsensitive) === requestedKey;
});
if (matches.length > 1) throw new AmbiguousNormalisedPathError(path);
if (matches.length === 1) {
resolvedParent = matches[0];
continue;
}
if (!allowMissing) return null;
const remainingPath = segments.slice(index).join("/");
return resolvedParent === "" ? remainingPath : `${resolvedParent}/${remainingPath}`;
}
return resolvedParent;
}
override async exists(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<boolean> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
if (resolvedPath === null) return false;
return await super.exists(resolvedPath);
}
override async trystat(path: string, caseInsensitive = this.isCaseInsensitive()) {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
if (resolvedPath === null) return null;
return await super.trystat(resolvedPath);
}
override async stat(path: string, caseInsensitive = this.isCaseInsensitive()) {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
if (resolvedPath === null) return null;
return await super.trystat(resolvedPath);
}
override async mkdir(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<void> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
await super.mkdir(resolvedPath);
}
override async remove(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<void> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
await super.remove(resolvedPath ?? path);
}
override async read(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<string> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
return await super.read(resolvedPath ?? path);
}
override async readBinary(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<ArrayBuffer> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
return await super.readBinary(resolvedPath ?? path);
}
override async write(
path: string,
data: string,
options?: Parameters<NodeStorageAdapter["write"]>[2],
caseInsensitive = this.isCaseInsensitive()
): Promise<void> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
await super.write(resolvedPath, data, options);
}
override async writeBinary(
path: string,
data: ArrayBuffer,
options?: Parameters<NodeStorageAdapter["writeBinary"]>[2],
caseInsensitive = this.isCaseInsensitive()
): Promise<void> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
await super.writeBinary(resolvedPath, data, options);
}
override async append(
path: string,
data: string,
options?: Parameters<NodeStorageAdapter["append"]>[2],
caseInsensitive = this.isCaseInsensitive()
): Promise<void> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
await super.append(resolvedPath, data, options);
}
override async rename(
sourcePath: string,
targetPath: string,
caseInsensitive = this.isCaseInsensitive()
): Promise<void> {
const resolvedSource = await this.resolveLogicalPath(sourcePath, caseInsensitive, false);
if (resolvedSource === null) return await super.rename(sourcePath, targetPath);
const resolvedTarget = await this.resolveLogicalPath(targetPath, caseInsensitive, true);
if (resolvedTarget === null) throw new Error(`Could not resolve path: ${targetPath}`);
// Preserve explicit case-only or Unicode-spelling renames when the target resolves to the source.
const sourceKey = this.comparisonKey(resolvedSource, caseInsensitive);
const targetKey = this.comparisonKey(resolvedTarget, caseInsensitive);
const target =
sourceKey === targetKey && resolvedTarget === resolvedSource && targetPath !== resolvedSource
? await this.resolveRenameTargetSpelling(targetPath, caseInsensitive)
: resolvedTarget;
await super.rename(resolvedSource, target);
}
override async list(basePath: string, caseInsensitive = this.isCaseInsensitive()) {
const resolvedPath = await this.resolveLogicalPath(basePath, caseInsensitive, false);
if (resolvedPath === null) return { files: [], folders: [] };
return await super.list(resolvedPath);
}
private async resolveRenameTargetSpelling(path: string, caseInsensitive: boolean): Promise<string> {
validateStoragePath(path, false);
const segments = path.split("/");
const leaf = segments.pop();
if (leaf === undefined) throw new Error(`Could not resolve path: ${path}`);
const parentPath = segments.join("/");
const resolvedParent = await this.resolveLogicalPath(parentPath, caseInsensitive, true);
if (resolvedParent === null) throw new Error(`Could not resolve path: ${path}`);
const entries = await super.list(resolvedParent);
const requestedKey = this.comparisonKey(leaf, caseInsensitive);
const matches = [...entries.files, ...entries.folders].filter((entryPath) => {
const entryName = entryPath.slice(resolvedParent === "" ? 0 : resolvedParent.length + 1);
return this.comparisonKey(entryName, caseInsensitive) === requestedKey;
});
if (matches.length > 1) throw new AmbiguousNormalisedPathError(path);
return resolvedParent === "" ? leaf : `${resolvedParent}/${leaf}`;
}
private comparisonKey(segment: string, caseInsensitive: boolean): string {
const normalisedSegment = segment.normalize("NFC");
return caseInsensitive ? normalisedSegment.toLowerCase() : normalisedSegment;
}
}
+43 -2
View File
@@ -6,6 +6,7 @@ import {
type ObsidianLiveSyncSettings,
type EntryDoc,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { isRemediationModeActive } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import {
activateRemoteConfiguration,
@@ -13,7 +14,7 @@ import {
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import type { CLICommandContext, CLIOptions } from "./types";
import { toArrayBuffer, toDatabaseRelativePath } from "./utils";
import { getForceRequirementError, toArrayBuffer, toDatabaseRelativePath } from "./utils";
import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./p2p";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { fsPromises as fs, getNodeFileCreationTime, path } from "@vrtmrz/livesync-commonlib/node";
@@ -37,8 +38,25 @@ function redactConnectionString(uri: string): string {
}
export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> {
const { databasePath, core, replicationScheduling, settingsPath } = context;
const { core } = context;
const { standardIo } = core.services.context;
const forceRequirementError = getForceRequirementError(options.command, options.force);
if (forceRequirementError) {
writeStderrLine(standardIo, `[Error] ${forceRequirementError}`);
return false;
}
if (
options.command === "fetch" &&
isRemediationModeActive(core.services.setting.currentSettings())
) {
writeStderrLine(
standardIo,
`[Error] The fetch command cannot run while remediation mode is active. Clear the modification-time limit before fetching.`
);
return false;
}
const { databasePath, replicationScheduling, settingsPath } = context;
const vaultPath = context.vaultPath || databasePath;
await core.services.control.activated;
@@ -519,6 +537,29 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
return true;
}
if (options.command === "rebuild") {
writeStderrLine(standardIo, "[Command] rebuild");
try {
await core.serviceModules.rebuilder.$rebuildEverything();
return true;
} catch (error) {
writeStderrLine(standardIo, "[Error] Rebuild failed:", error);
return false;
}
}
if (options.command === "fetch") {
writeStderrLine(standardIo, "[Command] fetch");
try {
// Fetch replaces the local database from remote; do not persist legacy local files first.
await core.serviceModules.rebuilder.$fetchLocal(false, true);
return true;
} catch (error) {
writeStderrLine(standardIo, "[Error] Fetch failed:", error);
return false;
}
}
if (options.command === "remote-add") {
if (options.commandArgs.length < 2) {
throw new Error("remote-add requires two arguments: <name> <connstr>");
@@ -118,6 +118,10 @@ function createCoreMock() {
fetch: vi.fn(async () => undefined),
storeContent: vi.fn(async () => true),
},
rebuilder: {
$rebuildEverything: vi.fn(async () => undefined),
$fetchLocal: vi.fn(async () => undefined),
},
},
} as any;
}
@@ -268,6 +272,73 @@ describe("runCommand abnormal cases", () => {
vi.restoreAllMocks();
});
it.each(["rebuild", "fetch"] as const)("requires --force before activating for %s", async (command) => {
const core = createCoreMock();
const activated = vi.fn(() => Promise.resolve());
Object.defineProperty(core.services.control, "activated", { get: activated });
await expect(runCommand(makeOptions(command, []), { ...context, core })).resolves.toBe(false);
expect(activated).not.toHaveBeenCalled();
expect(core.serviceModules.rebuilder.$rebuildEverything).not.toHaveBeenCalled();
expect(core.serviceModules.rebuilder.$fetchLocal).not.toHaveBeenCalled();
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
expect.stringContaining("requires --force")
);
});
it("dispatches a forced rebuild to the existing Rebuilder service", async () => {
const core = createCoreMock();
const options = makeOptions("rebuild", []);
options.force = true;
await expect(runCommand(options, { ...context, core })).resolves.toBe(true);
expect(core.serviceModules.rebuilder.$rebuildEverything).toHaveBeenCalledOnce();
expect(core.serviceModules.rebuilder.$fetchLocal).not.toHaveBeenCalled();
});
it("dispatches a forced fetch without saving legacy Vault files first", async () => {
const core = createCoreMock();
const options = makeOptions("fetch", []);
options.force = true;
await expect(runCommand(options, { ...context, core })).resolves.toBe(true);
expect(core.serviceModules.rebuilder.$fetchLocal).toHaveBeenCalledOnce();
expect(core.serviceModules.rebuilder.$fetchLocal).toHaveBeenCalledWith(false, true);
expect(core.serviceModules.rebuilder.$rebuildEverything).not.toHaveBeenCalled();
});
it("rejects fetch in remediation mode even when forced", async () => {
const core = createCoreMock();
core.services.setting.currentSettings().maxMTimeForReflectEvents = 1;
const options = makeOptions("fetch", []);
options.force = true;
await expect(runCommand(options, { ...context, core })).resolves.toBe(false);
expect(core.serviceModules.rebuilder.$fetchLocal).not.toHaveBeenCalled();
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
expect.stringContaining("remediation mode")
);
});
it("surfaces an internal rebuild refusal as a failed command", async () => {
const core = createCoreMock();
core.serviceModules.rebuilder.$rebuildEverything.mockRejectedValue(
new Error("Rebuilding is not available while remediation mode is active.")
);
const options = makeOptions("rebuild", []);
options.force = true;
await expect(runCommand(options, { ...context, core })).resolves.toBe(false);
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
expect.stringContaining("Rebuilding is not available while remediation mode is active")
);
});
it("retains visible progress for the interactive sync command", async () => {
const core = createCoreMock();
+4
View File
@@ -22,6 +22,8 @@ export type CLICommand =
| "rm"
| "resolve"
| "mirror"
| "rebuild"
| "fetch"
| "remote-add"
| "remote-rm"
| "remote-ls"
@@ -100,6 +102,8 @@ export const VALID_COMMANDS = new Set([
"rm",
"resolve",
"mirror",
"rebuild",
"fetch",
"remote-add",
"remote-rm",
"remote-ls",
+12
View File
@@ -1,4 +1,16 @@
import { path } from "@vrtmrz/livesync-commonlib/node";
import type { CLICommand } from "./types";
export function getForceRequirementError(command: CLICommand, force = false): string | undefined {
if (force) return undefined;
if (command === "rebuild") {
return "Command 'rebuild' requires --force because it replaces the local and remote databases.";
}
if (command === "fetch") {
return "Command 'fetch' requires --force because it replaces the local database with remote state.";
}
return undefined;
}
export function toArrayBuffer(data: Buffer): ArrayBuffer {
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
+15 -1
View File
@@ -1,6 +1,20 @@
import { path } from "@vrtmrz/livesync-commonlib/node";
import { describe, expect, it } from "vitest";
import { toDatabaseRelativePath } from "./utils";
import { getForceRequirementError, toDatabaseRelativePath } from "./utils";
describe("getForceRequirementError", () => {
it.each([
["rebuild", "Command 'rebuild' requires --force because it replaces the local and remote databases."],
["fetch", "Command 'fetch' requires --force because it replaces the local database with remote state."],
] as const)("requires force for %s", (command, expected) => {
expect(getForceRequirementError(command, false)).toBe(expected);
expect(getForceRequirementError(command, true)).toBeUndefined();
});
it("does not require force for ordinary commands", () => {
expect(getForceRequirementError("sync", false)).toBeUndefined();
});
});
describe("toDatabaseRelativePath", () => {
const databasePath = path.resolve("/tmp/livesync-vault");
+79 -2
View File
@@ -3,6 +3,7 @@ import { appendFile, mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as chokidar from "chokidar";
import * as offlineScanner from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import {
AutoAccepting,
@@ -20,6 +21,7 @@ import { cloneSettings } from "./settingsPersistence";
import type { CLICommand } from "./commands/types";
vi.mock("chokidar", { spy: true });
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", { spy: true });
function createStandardIoMock() {
return {
@@ -41,6 +43,7 @@ describe("CLI database preparation", () => {
beforeEach(async () => {
vi.mocked(chokidar.watch).mockClear();
vi.mocked(offlineScanner.useOfflineScanner).mockClear();
directory = await mkdtemp(join(tmpdir(), "livesync-cli-bootstrap-"));
vaultPath = join(directory, "vault");
settingsPath = join(directory, "settings.json");
@@ -69,12 +72,43 @@ describe("CLI database preparation", () => {
await rm(directory, { recursive: true, force: true });
});
async function start(command: CLICommand, runner: CliCommandRunner, exitCode = 1) {
process.argv = ["node", "livesync-cli", directory, "--vault", vaultPath, "--settings", settingsPath, command];
async function start(command: CLICommand, runner: CliCommandRunner, exitCode = 1, force = false) {
process.argv = [
"node",
"livesync-cli",
directory,
...(force ? ["--force"] : []),
"--vault",
vaultPath,
"--settings",
settingsPath,
command,
];
// Daemon probes return false so the real core unloads without keeping a daemon alive.
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
}
it("loads legacy device values and persists configuration through the service-owned store", async () => {
const localStoragePath = join(directory, ".livesync", "runtime", "local-storage.json");
await mkdir(join(directory, ".livesync", "runtime"), { recursive: true });
await writeFile(localStoragePath, JSON.stringify({ ["__proto__"]: "legacy value" }));
let legacyValue: string | null | undefined;
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
legacyValue = await core.services.setting.deviceValues.getValue("__proto__");
expect(core.services.config).toBe(core.services.setting.config);
await core.services.config.setValue("bootstrap-probe", "config value");
return false;
});
await start("ls", runner);
expect(legacyValue).toBe("legacy value");
const stored = JSON.parse(await readFile(localStoragePath, "utf-8")) as Record<string, string>;
expect(Object.prototype.hasOwnProperty.call(stored, "__proto__")).toBe(true);
expect(stored["__proto__"]).toBe("legacy value");
expect(Object.values(stored)).toContain("config value");
});
it.each([REMOTE_P2P, REMOTE_COUCHDB])(
"restores configured P2P automation before the host connects (%s)",
async (remoteType) => {
@@ -255,6 +289,49 @@ describe("CLI database preparation", () => {
expect(watch).not.toHaveBeenCalled();
});
it.each(["rebuild", "fetch"] as const)("does not scan or mirror legacy Vault files before %s", async (command) => {
const storedPaths: string[] = [];
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
for await (const doc of core.services.database.localDatabase.findAllNormalDocs()) {
storedPaths.push(doc.path);
}
return false;
});
await start(command, runner, 1, true);
expect(runner).toHaveBeenCalledOnce();
expect(storedPaths).toEqual([]);
expect(await readFile(join(vaultPath, "notes/local.md"), "utf-8")).toBe("local content");
expect(vi.mocked(chokidar.watch)).not.toHaveBeenCalled();
});
it("registers the Rebuild scanner after readiness and makes its Vault scan available", async () => {
const enumerate = vi.spyOn(ServiceFileAccessCLI.prototype, "getFiles");
const useScanner = vi.mocked(offlineScanner.useOfflineScanner);
let scansBeforeCommand = -1;
let scannerRegistrationsBeforeCommand = -1;
let storedPaths: string[] = [];
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
scansBeforeCommand = enumerate.mock.calls.length;
scannerRegistrationsBeforeCommand = useScanner.mock.calls.length;
await core.services.vault.scanVault(true, true);
for await (const doc of core.services.database.localDatabase.findAllNormalDocs()) {
storedPaths.push(doc.path);
}
return false;
});
await start("rebuild", runner, 1, true);
expect(useScanner).toHaveBeenCalledOnce();
expect(scannerRegistrationsBeforeCommand).toBe(1);
expect(scansBeforeCommand).toBe(0);
expect(enumerate).toHaveBeenCalledOnce();
expect(storedPaths).toEqual(["notes/local.md"]);
});
it.each([
{ command: "daemon" as const, commandRuns: true },
{ command: "mirror" as const, commandRuns: false },
+29 -6
View File
@@ -1,6 +1,5 @@
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore";
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
import {
@@ -21,6 +20,7 @@ import {
import { runCommand } from "./commands/runCommand";
import { isCLICommand } from "./commands/types";
import type { CLICommand, CLICommandContext, CLIOptions } from "./commands/types";
import { getForceRequirementError } from "./commands/utils";
import { getPathFromUXFileInfo } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { IgnoreRules } from "./serviceModules/IgnoreRules";
@@ -47,6 +47,7 @@ const SETTINGS_FILE = ".livesync/settings.json";
interface CLIVaultSyncMode {
readonly watchFiles: boolean;
readonly reflectReplicationResults: boolean;
readonly scanOnStartup?: boolean;
readonly startupDatabaseOptions: StartupDatabaseOptions;
}
@@ -62,9 +63,20 @@ const VAULT_SYNC_MODES: Readonly<Partial<Record<CLICommand, CLIVaultSyncMode>>>
reflectReplicationResults: false,
startupDatabaseOptions: { ignoreSuspending: true, continueOnFileFailure: false },
},
rebuild: {
watchFiles: false,
reflectReplicationResults: false,
scanOnStartup: false,
startupDatabaseOptions: { ignoreSuspending: false, continueOnFileFailure: false },
},
fetch: {
watchFiles: false,
reflectReplicationResults: true,
scanOnStartup: false,
startupDatabaseOptions: { ignoreSuspending: false, continueOnFileFailure: false },
},
};
ensureGlobalNodeLocalStorage();
defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
/** Injectable command boundary used by CLI integration probes. */
@@ -90,6 +102,8 @@ Commands:
p2p-sync <peer> <timeout>
Sync with the specified peer-id or peer-name
p2p-host Start P2P host mode and wait until interrupted
rebuild Rebuild local and remote databases from the selected Vault (requires --force)
fetch Replace the local database with remote state and reflect it to the Vault (requires --force)
push <src> <dst> Push local file <src> into local database path <dst>
pull <src> <dst> Pull file <src> from local database into local file <dst>
pull-rev <src> <dst> <rev> Pull file <src> at specific revision <rev> into local file <dst>
@@ -122,8 +136,9 @@ Commands:
Show remote database status
Options:
--vault <path>, -V <path> (daemon/mirror) Path to the vault directory containing .md files
--vault <path>, -V <path> (daemon/mirror/rebuild/fetch) Path to the vault directory containing .md files
(defaults to database-path; allows separate PouchDB and vault dirs)
--force, -f Required for rebuild and fetch because they replace databases
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
--write-settings Write setting changes after a successful command
--compat-remote-admin-exit-zero
@@ -319,6 +334,12 @@ export async function main(
commandRunner: CliCommandRunner = runCommand
) {
const options = parseArgs(standardIo);
const forceRequirementError = getForceRequirementError(options.command, options.force);
if (forceRequirementError) {
writeStderrLine(standardIo, `[Error] ${forceRequirementError}`);
process.exitCode = 1;
return;
}
const vaultSyncMode = VAULT_SYNC_MODES[options.command];
if (options.interval && options.command !== "daemon") {
writeStderrLine(
@@ -377,8 +398,6 @@ export async function main(
const settingsPath = options.settingsPath
? path.resolve(options.settingsPath)
: path.join(databasePath, SETTINGS_FILE);
configureNodeLocalStorage(path.join(databasePath, ".livesync", "runtime", "local-storage.json"));
// Resolve vault path: mirror positional argument takes priority,
// then --vault flag, otherwise fall back to databasePath.
const vaultPath =
@@ -521,7 +540,7 @@ export async function main(
() => [], // No add-ons
(core, coreFeatureViews) => {
replicationScheduling = coreFeatureViews.replicationScheduling;
if (vaultSyncMode) {
if (vaultSyncMode && vaultSyncMode.scanOnStartup !== false) {
useOfflineScanner(core);
}
// Register P2P replicator feature.
@@ -641,6 +660,10 @@ export async function main(
if (!readyResult) {
throw new Error("Failed to initialise LiveSync.");
}
if (vaultSyncMode?.scanOnStartup === false) {
// Keep the startup reset clean, then enable scans for the explicit Rebuild or Fetch operation.
useOfflineScanner(core);
}
const settingsBeforeCommand = cloneSettings(core.services.setting.currentSettings());
const transientSettingKeys = changedSettingKeys(settingsBeforeSuspend, settingsAfterSuspend);
for (const key of CLI_RUNTIME_ONLY_SETTING_KEYS) {
+25 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseArgs as parseCliArgs } from "./main";
import { main as runCliMain, parseArgs as parseCliArgs } from "./main";
function createStandardIoMock() {
return {
@@ -19,6 +19,7 @@ function mockProcessExit() {
describe("CLI parseArgs", () => {
const originalArgv = process.argv.slice();
const originalExitCode = process.exitCode;
let standardIo: ReturnType<typeof createStandardIoMock>;
beforeEach(() => {
@@ -29,6 +30,7 @@ describe("CLI parseArgs", () => {
afterEach(() => {
process.argv = originalArgv.slice();
process.exitCode = originalExitCode;
vi.restoreAllMocks();
});
@@ -99,6 +101,28 @@ describe("CLI parseArgs", () => {
expect(parsed.commandArgs).toEqual([]);
});
it.each(["rebuild", "fetch"] as const)("parses %s and its force flag", (command) => {
process.argv = ["node", "livesync-cli", "./databasePath", "--force", command];
const parsed = parseArgs();
expect(parsed.command).toBe(command);
expect(parsed.force).toBe(true);
expect(parsed.commandArgs).toEqual([]);
});
it.each(["rebuild", "fetch"] as const)("rejects %s without force before database access", async (command) => {
process.argv = ["node", "livesync-cli", "./missing-database", command];
process.exitCode = undefined;
const runner = vi.fn(async () => true);
await expect(runCliMain(standardIo, runner as any)).resolves.toBeUndefined();
expect(runner).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
expect(standardIo.writeStderr).toHaveBeenCalledWith(expect.stringContaining("requires --force"));
expect(standardIo.writeStderr).not.toHaveBeenCalledWith(expect.stringContaining("Database directory"));
});
it("parses remote-add command", () => {
process.argv = [
"node",
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.37-cli",
"version": "1.0.42-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
@@ -12,7 +12,7 @@
"buildRun": "npm run build && npm run cli --",
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
"check": "tsc -p tsconfig.json",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
@@ -52,7 +52,7 @@
},
"devDependencies": {
"typescript": "6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.11"
"vite": "^8.1.6",
"vitest": "^4.1.12"
}
}
@@ -99,6 +99,7 @@ export function initialiseServiceModulesCLI(
// Rebuilder (platform-independent)
const rebuilder = new ServiceRebuilder({
path: services.path,
events: services.context.events,
API: services.API,
database: services.database,
+22 -7
View File
@@ -1,6 +1,10 @@
import { FileAccessBase, type FileAccessBaseDependencies } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
import {
FileAccessBase,
type FileAccessBaseDependencies,
} from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
import { NodeFileSystemAdapter } from "@/apps/cli/adapters/NodeFileSystemAdapter";
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
import { getPathNormalizationVersion } from "@vrtmrz/livesync-commonlib/settings";
/**
* CLI-specific implementation of FileAccessBase
@@ -8,12 +12,23 @@ import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
*/
export class FileAccessCLI extends FileAccessBase<NodeFileSystemAdapter> {
constructor(basePath: string, dependencies: FileAccessBaseDependencies) {
const adapter = new NodeFileSystemAdapter(basePath, (message, detail) => {
dependencies.APIService.addLog(message, LOG_LEVEL_NOTICE);
if (detail !== undefined) {
dependencies.APIService.addLog(detail, LOG_LEVEL_NOTICE);
}
});
const pathNormalizationVersion = () =>
getPathNormalizationVersion(dependencies.settingService.currentSettings());
const isCaseInsensitive = () => {
const settings = dependencies.settingService.currentSettings();
return !settings.handleFilenameCaseSensitive || dependencies.vaultService.isStorageInsensitive();
};
const adapter = new NodeFileSystemAdapter(
basePath,
(message, detail) => {
dependencies.APIService.addLog(message, LOG_LEVEL_NOTICE);
if (detail !== undefined) {
dependencies.APIService.addLog(detail, LOG_LEVEL_NOTICE);
}
},
pathNormalizationVersion,
isCaseInsensitive
);
super(adapter, dependencies);
}
+65 -94
View File
@@ -1,115 +1,86 @@
import { createDeletableValueStore, sanitiseValueStoreError, ValueStoreError } from "@vrtmrz/value-store";
import type { ValueDeleter, ValueStore } from "@vrtmrz/value-store";
import { fs as nodeFs, path as nodePath } from "@vrtmrz/livesync-commonlib/node";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
type LocalStorageShape = {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
clear(): void;
};
type NodeLocalStorage = ValueStore & ValueDeleter;
class PersistentNodeLocalStorage {
private storagePath: string | undefined;
private localStore: Record<string, string> = {};
class JsonFileValueBackend {
private values: Map<string, string>;
configure(storagePath: string) {
if (this.storagePath === storagePath) {
return;
}
this.storagePath = storagePath;
this.loadFromFile();
constructor(private readonly storagePath: string) {
this.values = this.loadValues();
}
private loadFromFile() {
if (!this.storagePath) {
this.localStore = {};
get(key: string): string | null {
return this.values.get(key) ?? null;
}
set(key: string, value: string): void {
const nextValues = new Map(this.values);
nextValues.set(key, value);
this.writeValues(nextValues);
this.values = nextValues;
}
delete(key: string): void {
if (!this.values.has(key)) {
return;
}
const nextValues = new Map(this.values);
nextValues.delete(key);
this.writeValues(nextValues);
this.values = nextValues;
}
private loadValues(): Map<string, string> {
let contents: string;
try {
const loaded = JSON.parse(nodeFs.readFileSync(this.storagePath, "utf-8")) as Record<string, string>;
this.localStore = { ...loaded };
contents = nodeFs.readFileSync(this.storagePath, "utf-8");
} catch (error) {
if (isMissingFileError(error)) {
return new Map();
}
throw sanitiseValueStoreError(error);
}
let document: unknown;
try {
document = JSON.parse(contents);
} catch {
this.localStore = {};
throw new ValueStoreError("invalid-data");
}
if (document === null || typeof document !== "object" || Array.isArray(document)) {
throw new ValueStoreError("invalid-data");
}
const values = new Map<string, string>();
for (const [key, value] of Object.entries(document)) {
if (typeof value !== "string") {
throw new ValueStoreError("invalid-data");
}
values.set(key, value);
}
return values;
}
private flushToFile() {
if (!this.storagePath) {
return;
}
private writeValues(values: ReadonlyMap<string, string>): void {
nodeFs.mkdirSync(nodePath.dirname(this.storagePath), { recursive: true });
nodeFs.writeFileSync(this.storagePath, JSON.stringify(this.localStore, null, 2), "utf-8");
}
getItem(key: string): string | null {
return this.localStore[key] ?? null;
}
setItem(key: string, value: string) {
this.localStore[key] = value;
this.flushToFile();
}
removeItem(key: string) {
if (!(key in this.localStore)) {
return;
}
delete this.localStore[key];
this.flushToFile();
}
clear() {
this.localStore = {};
this.flushToFile();
nodeFs.writeFileSync(this.storagePath, JSON.stringify(Object.fromEntries(values), null, 2), "utf-8");
}
}
const persistentNodeLocalStorage = new PersistentNodeLocalStorage();
function createNodeLocalStorageShim(): LocalStorageShape {
return {
getItem(key: string) {
return persistentNodeLocalStorage.getItem(key);
},
setItem(key: string, value: string) {
persistentNodeLocalStorage.setItem(key, value);
},
removeItem(key: string) {
persistentNodeLocalStorage.removeItem(key);
},
clear() {
persistentNodeLocalStorage.clear();
},
};
}
export function ensureGlobalNodeLocalStorage() {
const g = compatGlobal as unknown as Record<string, unknown>;
if (
!("localStorage" in g) ||
typeof (g.localStorage as Record<string, unknown> | undefined)?.getItem !== "function"
) {
g.localStorage = createNodeLocalStorageShim();
function isMissingFileError(error: unknown): boolean {
if (typeof error !== "object" || error === null) {
return false;
}
try {
const code = Object.getOwnPropertyDescriptor(error, "code");
return code !== undefined && Object.prototype.hasOwnProperty.call(code, "value") && code.value === "ENOENT";
} catch {
return false;
}
}
export function configureNodeLocalStorage(storagePath: string) {
persistentNodeLocalStorage.configure(storagePath);
ensureGlobalNodeLocalStorage();
}
export function getNodeLocalStorageItem(key: string): string {
return persistentNodeLocalStorage.getItem(key) ?? "";
}
export function setNodeLocalStorageItem(key: string, value: string) {
persistentNodeLocalStorage.setItem(key, value);
}
export function deleteNodeLocalStorageItem(key: string) {
persistentNodeLocalStorage.removeItem(key);
}
export function clearNodeLocalStorage() {
persistentNodeLocalStorage.clear();
export function createNodeLocalStorage(storagePath: string): NodeLocalStorage {
return createDeletableValueStore(new JsonFileValueBackend(storagePath));
}
@@ -1,58 +1,127 @@
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import { ValueStoreError } from "@vrtmrz/value-store";
import { afterEach, describe, expect, it } from "vitest";
import {
clearNodeLocalStorage,
configureNodeLocalStorage,
ensureGlobalNodeLocalStorage,
getNodeLocalStorageItem,
setNodeLocalStorageItem,
} from "./NodeLocalStorage";
import { createNodeLocalStorage } from "./NodeLocalStorage";
describe("NodeLocalStorage", () => {
const tempDirs: string[] = [];
afterEach(() => {
clearNodeLocalStorage();
for (const tempDir of tempDirs.splice(0)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("persists values to the configured file", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
configureNodeLocalStorage(storagePath);
setNodeLocalStorageItem("checkpoint", "42");
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
expect(saved.checkpoint).toBe("42");
});
it("reloads persisted values when configured again", () => {
it("loads and preserves legacy flat string data, including empty values and exact keys", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
const specialKey = "__proto__";
const specialValue = "line one\n雪";
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
fs.writeFileSync(storagePath, JSON.stringify({ persisted: "value" }, null, 2), "utf-8");
fs.writeFileSync(storagePath, JSON.stringify({ [specialKey]: specialValue, empty: "" }), "utf-8");
configureNodeLocalStorage(storagePath);
const store = createNodeLocalStorage(storagePath);
expect(getNodeLocalStorageItem("persisted")).toBe("value");
await expect(store.getValue(specialKey)).resolves.toBe(specialValue);
await expect(store.getValue("empty", "default")).resolves.toBe("");
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
expect(saved[specialKey]).toBe(specialValue);
expect(saved.empty).toBe("");
});
it("installs a global localStorage shim backed by the same store", () => {
it("persists strings and objects, reloads them, and deletes keys", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
const store = createNodeLocalStorage(storagePath);
const object = { enabled: false, entries: ["value", null, 0] };
configureNodeLocalStorage(storagePath);
ensureGlobalNodeLocalStorage();
await store.setValue("empty", "");
await store.setValue("text", "stored value");
await store.setObject("object", object);
globalThis.localStorage.setItem("shared", "state");
const reloaded = createNodeLocalStorage(storagePath);
await expect(reloaded.getValue("empty", "default")).resolves.toBe("");
await expect(reloaded.getValue("text")).resolves.toBe("stored value");
await expect(reloaded.getObject("object")).resolves.toEqual(object);
expect(getNodeLocalStorageItem("shared")).toBe("state");
await reloaded.delete("text");
await expect(reloaded.getValue("text")).resolves.toBeNull();
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
expect(saved.text).toBeUndefined();
expect(saved.object).toBe(JSON.stringify(object));
});
it("treats a missing file as empty and creates its parent on the first write", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
const store = createNodeLocalStorage(storagePath);
await expect(store.getValue("absent")).resolves.toBeNull();
expect(fs.existsSync(storagePath)).toBe(false);
await store.setValue("created", "value");
expect(fs.existsSync(storagePath)).toBe(true);
});
it("keeps separate store instances isolated by their file paths", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const first = createNodeLocalStorage(path.join(tempDir, "first", "local-storage.json"));
const second = createNodeLocalStorage(path.join(tempDir, "second", "local-storage.json"));
await first.setValue("shared-key", "first");
await second.setValue("shared-key", "second");
await expect(first.getValue("shared-key")).resolves.toBe("first");
await expect(second.getValue("shared-key")).resolves.toBe("second");
});
it("surfaces malformed and unreadable files as sanitised value-store errors", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const malformedPath = path.join(tempDir, "malformed.json");
fs.writeFileSync(malformedPath, "{ malformed", "utf-8");
let malformedError: unknown;
try {
createNodeLocalStorage(malformedPath);
} catch (error) {
malformedError = error;
}
expect(malformedError).toBeInstanceOf(ValueStoreError);
expect(malformedError).toMatchObject({ code: "invalid-data" });
expect(fs.readFileSync(malformedPath, "utf-8")).toBe("{ malformed");
const unreadablePath = path.join(tempDir, "unreadable.json");
fs.mkdirSync(unreadablePath);
expect(() => createNodeLocalStorage(unreadablePath)).toThrowError(ValueStoreError);
});
it("does not change its visible values when a write fails", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
const store = createNodeLocalStorage(storagePath);
await store.setValue("retained", "before failure");
fs.rmSync(storagePath);
fs.mkdirSync(storagePath);
await expect(store.setValue("new", "write fails")).rejects.toBeInstanceOf(ValueStoreError);
await expect(store.getValue("retained")).resolves.toBe("before failure");
await expect(store.getValue("new")).resolves.toBeNull();
});
it("does not install or modify a global localStorage value", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const before = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
createNodeLocalStorage(path.join(tempDir, "runtime", "local-storage.json"));
expect(Object.getOwnPropertyDescriptor(globalThis, "localStorage")).toEqual(before);
});
});
+8 -11
View File
@@ -1,6 +1,5 @@
import type { AppLifecycleServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/ConfigServiceBrowserCompat";
import type {
ComponentHasResult,
SvelteDialogManager,
@@ -11,6 +10,7 @@ import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService";
import { InjectableFileProcessingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableFileProcessingService";
import { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService";
import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService";
@@ -21,7 +21,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import { HeadlessAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/headless/HeadlessAPIService";
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
import { NodeSettingService } from "./NodeSettingService";
import { createNodeLocalStorage } from "./NodeLocalStorage";
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { path as nodePath } from "@vrtmrz/livesync-commonlib/node";
@@ -96,11 +96,11 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
const conflict = new InjectableConflictService(context);
const fileProcessing = new InjectableFileProcessingService(context);
const setting = new NodeSettingService(
context,
{ APIService: API, onDisplayLanguageChanged: setLang },
localStoragePath
);
const setting = new InjectableSettingService<T>(context, {
APIService: API,
onDisplayLanguageChanged: setLang,
deviceValues: createNodeLocalStorage(localStoragePath),
});
const appLifecycle = new NodeAppLifecycleService<T>(context, {
settingService: setting,
@@ -132,10 +132,7 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
setting,
});
const config = new ConfigServiceBrowserCompat<T>(context, {
settingService: setting,
APIService: API,
});
const config = setting.config;
const replicator = new InjectableReplicatorService(context, {
settingService: setting,
@@ -1,42 +0,0 @@
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { EVENT_REQUEST_RELOAD_SETTING_TAB } from "@/common/events";
import { handlers } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { SettingService, type SettingServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import {
configureNodeLocalStorage,
deleteNodeLocalStorageItem,
getNodeLocalStorageItem,
setNodeLocalStorageItem,
} from "./NodeLocalStorage";
export class NodeSettingService<T extends ServiceContext> extends SettingService<T> {
constructor(context: T, dependencies: SettingServiceDependencies, storagePath: string) {
super(context, dependencies);
configureNodeLocalStorage(storagePath);
this.onSettingSaved.addHandler((settings) => {
this.context.events.emitEvent(EVENT_SETTING_SAVED, settings);
return Promise.resolve(true);
});
this.onSettingLoaded.addHandler((settings) => {
this.context.events.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
return Promise.resolve(true);
});
}
protected setItem(key: string, value: string) {
setNodeLocalStorageItem(key, value);
}
protected getItem(key: string): string {
return getNodeLocalStorageItem(key);
}
protected deleteItem(key: string): void {
deleteNodeLocalStorageItem(key);
}
public saveData = handlers<{ saveData: (data: ObsidianLiveSyncSettings) => Promise<void> }>().binder("saveData");
public loadData = handlers<{ loadData: () => Promise<ObsidianLiveSyncSettings | undefined> }>().binder("loadData");
}
+3 -3
View File
@@ -146,7 +146,7 @@ These flows do not require Bash on the host. The S3 matrix tasks, environment va
### `test-mirror.ts`
- Verifies six core mirror scenarios.
- Verifies seven core mirror scenarios, including an unrecognised revision origin.
- Does not require an external remote.
### `test-sync-two-local-databases.ts`
@@ -247,11 +247,12 @@ deno task test:daemon
deno task test:push-pull
deno task test:sync-locked-remote
# CouchDB-based tests
# CouchDB and Object Storage tests
deno task test:sync-two-local
deno task test:decoupled-vault
deno task test:remote-commands
deno task test:e2e-couchdb
deno task test:e2e-matrix
# Explicit host-direct P2P diagnostics for cross-platform investigations.
# These are not part of the default suite or release evidence.
@@ -260,7 +261,6 @@ deno task test:p2p-peers
deno task test:p2p-sync
deno task test:p2p-three-nodes
deno task test:p2p-upload-download
deno task test:e2e-matrix
```
---
+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 { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
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 {
createLiveSyncBrowserServiceHub,
@@ -56,6 +58,7 @@ const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success";
export interface WebAppRuntimeOptions {
deviceValues?: ValueStore & ValueDeleter;
reportStatus?: (kind: WebAppRuntimeStatusKind, message: string) => void;
scheduleReload?: (delayMilliseconds: number) => void;
}
@@ -71,7 +74,10 @@ export class WebAppRuntime {
private paneHost: P2PReplicatorPaneHost | null = null;
private restartScheduled = false;
constructor(rootHandle: FileSystemDirectoryHandle, options: WebAppRuntimeOptions = {}) {
constructor(
rootHandle: FileSystemDirectoryHandle,
private readonly options: WebAppRuntimeOptions = {}
) {
this.rootHandle = rootHandle;
this.reportStatus = options.reportStatus ?? (() => {});
this.scheduleReload =
@@ -89,6 +95,8 @@ export class WebAppRuntime {
private createServiceOptions(): LiveSyncBrowserServiceHubOptions<ServiceContext> {
return {
deviceValues:
this.options.deviceValues ?? createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
getSystemVaultName: () => this.rootHandle.name || "livesync-webapp",
settings: {
save: async (data) => {
@@ -219,7 +227,9 @@ export class WebAppRuntime {
useCheckRemoteSize(core);
useRemoteConfiguration(core);
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 = {
services: core.services,
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.37-webapp",
"version": "1.0.42-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
@@ -21,6 +21,6 @@
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "5.56.3",
"typescript": "6.0.3",
"vite": "^8.0.16"
"vite": "^8.1.6"
}
}
@@ -90,6 +90,7 @@ export function initialiseServiceModulesFSAPI(
// Rebuilder (platform-independent)
const rebuilder = new ServiceRebuilder({
path: services.path,
events: services.context.events,
API: services.API,
database: services.database,
+2
View File
@@ -49,6 +49,8 @@ After the first device connects, **Try another device without resetting** keeps
Serve the production build over HTTPS or from `localhost`. Downloading `check.html` alone is not supported because the page uses built module assets and origin-scoped browser storage. The generated credentials are temporary and must not replace the settings of a production Vault.
The generated Setup URI is Time-bound, and the page shows when its current fixed UTC window ends. After that time, the page hides the URI and QR code and disables the additional-device action. Start a fresh check to configure another device. A monitor for a device which already imported the settings remains available.
The detailed scope and result semantics are recorded in the [browser-assisted P2P connection-check ADR](../../../docs/adr/2026_07_p2p_connection_check.md).
## Storage and lifecycle
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.37-webpeer",
"version": "1.0.42-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
@@ -24,6 +24,6 @@
"svelte": "5.56.3",
"svelte-check": "^4.6.0",
"typescript": "6.0.3",
"vite": "^8.0.16"
"vite": "^8.1.6"
}
}
+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 { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
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 type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
@@ -20,6 +22,7 @@ import { translateLiveSyncMessage } from "@/common/translation";
import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersistence";
export interface WebPeerRuntimeOptions {
deviceValues?: ValueStore & ValueDeleter;
context?: ServiceContext;
store?: SimpleStore<unknown>;
deviceName?: string;
@@ -62,6 +65,7 @@ export class WebPeerRuntime {
this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage });
this.services = createLiveSyncBrowserServiceHub<ServiceContext>({
context: this.context,
deviceValues: options.deviceValues ?? createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
getSystemVaultName: () => options.systemVaultName ?? WEBPEER_STORE_NAME,
settings: persistence.settings,
restart: {
@@ -103,7 +107,7 @@ export class WebPeerRuntime {
await this.services.setting.loadSettings();
const deviceName = this.options.deviceName?.trim();
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({
replicator: this.services.replicator,
@@ -192,7 +196,7 @@ export class WebPeerRuntime {
);
}
getDeviceName(): string {
return this.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || this.services.vault.getVaultName();
async getDeviceName(): Promise<string> {
return (await this.services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME)) || this.services.vault.getVaultName();
}
}
+2
View File
@@ -19,6 +19,7 @@ export const EVENT_REQUEST_RUN_DOCTOR = "request-run-doctor";
export const EVENT_REQUEST_RUN_FIX_INCOMPLETE = "request-run-fix-incomplete";
export const EVENT_ANALYSE_DB_USAGE = "analyse-db-usage";
export const EVENT_INSPECT_DB_INTEGRITY = "inspect-db-integrity";
export const EVENT_REQUEST_PERFORM_GC_V3 = "request-perform-gc-v3";
// export const EVENT_REQUEST_CHECK_REMOTE_SIZE = "request-check-remote-size";
// export const EVENT_FILE_CHANGED = "file-changed";
@@ -37,6 +38,7 @@ declare global {
[EVENT_REQUEST_RUN_DOCTOR]: string;
[EVENT_REQUEST_RUN_FIX_INCOMPLETE]: undefined;
[EVENT_ANALYSE_DB_USAGE]: undefined;
[EVENT_INSPECT_DB_INTEGRITY]: undefined;
[EVENT_REQUEST_PERFORM_GC_V3]: undefined;
}
}
@@ -7,6 +7,111 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.":
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.",
"Hidden File Sync initialisation was not started.": "Hidden File Sync initialisation was not started.",
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.":
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.",
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.":
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.",
"Unknown platform": "Unknown platform",
"This removes only this device's record from the remote.":
"This removes only this device's record from the remote.",
"This remote is unlocked. A device that returns can register again.":
"This remote is unlocked. A device that returns can register again.",
"This remote is locked. Use the existing recovery process for devices that need to join again.":
"This remote is locked. Use the existing recovery process for devices that need to join again.",
"This remote is locked. If the device returns, use the existing recovery process to join it again.":
"This remote is locked. If the device returns, use the existing recovery process to join it again.",
"This remote does not support conditional updates, so the device record could not be removed.":
"This remote does not support conditional updates, so the device record could not be removed.",
"This remote does not currently have a record of joined devices.":
"This remote does not currently have a record of joined devices.",
"This list shows device information recorded by the remote. It does not show whether a device is online now.":
"This list shows device information recorded by the remote. It does not show whether a device is online now.",
"This device has been locked out. Use the existing recovery process to join again.":
"This device has been locked out. Use the existing recovery process to join again.",
"This device": "This device",
"This remote has been cleaned. Use the existing recovery process to join devices again as needed.":
"This remote has been cleaned. Use the existing recovery process to join devices again as needed.",
"The device record was forgotten.": "The device record was forgotten.",
"The device record was already absent.": "The device record was already absent.",
"The device list changed while you were trying to forget this device. Refresh the list before trying again.":
"The device list changed while you were trying to forget this device. Refresh the list before trying again.",
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed.":
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed.",
"Review device records for the active CouchDB or Object Storage connection.":
"Review device records for the active CouchDB or Object Storage connection.",
Refresh: "Refresh",
"Plug-in version": "Plug-in version",
Platform: "Platform",
"Older Self-hosted LiveSync versions detected: ${versions}.":
"Older Self-hosted LiveSync versions detected: ${versions}.",
"Newer Self-hosted LiveSync versions detected: ${versions}.":
"Newer Self-hosted LiveSync versions detected: ${versions}.",
"Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.":
"Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.",
Open: "Open",
"Not recorded": "Not recorded",
"Not accepted": "Not accepted",
"No joined devices are recorded on this remote.": "No joined devices are recorded on this remote.",
"Locked out": "Locked out",
"Loading joined devices…": "Loading joined devices…",
"Last recorded": "Last recorded",
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again.":
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again.",
"Joined devices are available after database startup with an active CouchDB or Object Storage connection.":
"Joined devices are available after database startup with an active CouchDB or Object Storage connection.",
"Joined devices": "Joined devices",
"Review devices": "Review devices",
"Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable.":
"Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable.",
"Forgetting…": "Forgetting…",
"Forgetting the record does not delete Vault files or revoke credentials.":
"Forgetting the record does not delete Vault files or revoke credentials.",
"Forget this device record?": "Forget this device record?",
"Forget device": "Forget device",
Close: "Close",
Cancel: "Cancel",
"App version": "App version",
Accepted: "Accepted",
"Preparing follow-up report…": "Preparing follow-up report…",
"Cancelling local search. Checked ${COUNT} entries.": "Cancelling local search. Checked ${COUNT} entries.",
"Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.":
"Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.",
"The lookup does not read file contents or contact a remote database. Follow-up reports include current conflicts and merge bases.":
"The lookup does not read file contents or contact a remote database. Follow-up reports include current conflicts and merge bases.",
"Local file diagnostic report": "Local file diagnostic report",
"Checked ${COUNT} local entries.": "Checked ${COUNT} local entries.",
"Enter a valid report path pseudonym.": "Enter a valid report path pseudonym.",
"Search cancelled. Matches found so far are shown.": "Search cancelled. Matches found so far are shown.",
"No matches were found in current Vault paths or current local Metadata. Older history and remote-only files are not searched.":
"No matches were found in current Vault paths or current local Metadata. Older history and remote-only files are not searched.",
"Found ${COUNT} local matches.": "Found ${COUNT} local matches.",
"The local search could not finish.": "The local search could not finish.",
"The file could not be opened.": "The file could not be opened.",
"The follow-up report could not be prepared.": "The follow-up report could not be prepared.",
"Report path pseudonym": "Report path pseudonym",
Source: "Source",
Metadata: "Metadata",
Vault: "Vault",
"Metadata document ID": "Metadata document ID",
"No current Metadata document": "No current Metadata document",
"Current Vault file": "Current Vault file",
Present: "Present",
Missing: "Missing",
"Open file": "Open file",
"Prepare follow-up report": "Prepare follow-up report",
"This report uses pseudonyms. Review its coverage before sharing.":
"This report uses pseudonyms. Review its coverage before sharing.",
"Back to matches": "Back to matches",
"Reading revisions": "Reading revisions",
"Calculating database usage": "Calculating database usage",
"Discovering revisions": "Discovering revisions",
"Find a file from a report pseudonym": "Find a file from a report pseudonym",
"Find matching paths in this Vault and local Metadata, then prepare a follow-up report using the same pseudonyms.":
"Find matching paths in this Vault and local Metadata, then prepare a follow-up report using the same pseudonyms.",
"Find file": "Find file",
"⚠️ Initialise or overwrite the remote": "⚠️ Initialise or overwrite the remote",
"🔗 Join this device": "🔗 Join this device",
"⚙️ Apply settings only (advanced)": "⚙️ Apply settings only (advanced)",
@@ -251,6 +356,48 @@ export const liveSyncProvisionalEnglishMessages = {
"An ID source is required to enable this option.": "An ID source is required to enable this option.",
"The ID source or recovery code is invalid. Check it and try again.":
"The ID source or recovery code is invalid. Check it and try again.",
"Read normal-file Metadata and current conflicts locally and in CouchDB, then compare content for suspect groups. Choose original names or pseudonyms before starting.":
"Read normal-file Metadata and current conflicts locally and in CouchDB, then compare content for suspect groups. Choose original names or pseudonyms before starting.",
Inspect: "Inspect",
"Inspect database integrity": "Inspect database integrity",
"Inspection completed. Review the findings and coverage before sharing.":
"Inspection completed. Review the findings and coverage before sharing.",
"Inspection partially completed. Some information could not be checked; review the reported reasons.":
"Inspection partially completed. Some information could not be checked; review the reported reasons.",
"Inspection failed. The report records the available evidence and reasons.":
"Inspection failed. The report records the available evidence and reasons.",
"Inspection cancelled. The report contains only the evidence collected so far.":
"Inspection cancelled. The report contains only the evidence collected so far.",
"Report copied.": "Report copied.",
"Copy failed. Select the report text and copy it manually.":
"Copy failed. Select the report text and copy it manually.",
"Download requested. You can also copy the report if downloads are unavailable on this device.":
"Download requested. You can also copy the report if downloads are unavailable on this device.",
"Pseudonyms include a public salt, so guessed paths can be checked. Original names include private filenames. Reports omit file contents and credentials.":
"Pseudonyms include a public salt, so guessed paths can be checked. Original names include private filenames. Reports omit file contents and credentials.",
"Read available local revisions and report their Chunk references. Settings are left unchanged.":
"Read available local revisions and report their Chunk references. Settings are left unchanged.",
"Inspect normal-file Metadata, current conflicts, and suspect content against the Vault. Remote inspection supports CouchDB. Older history is excluded; findings do not authorise deletion.":
"Inspect normal-file Metadata, current conflicts, and suspect content against the Vault. Remote inspection supports CouchDB. Older history is excluded; findings do not authorise deletion.",
"Include remote database": "Include remote database",
"Names in this report": "Names in this report",
"Pseudonyms (recommended)": "Pseudonyms (recommended)",
"Original names": "Original names",
"Cancelling inspection…": "Cancelling inspection…",
"Items inspected": "Items inspected",
"Cancel inspection": "Cancel inspection",
"This report uses temporary pseudonyms. Counts and relationships remain visible.":
"This report uses temporary pseudonyms. Counts and relationships remain visible.",
"This report contains original paths and identifiers. Review them before sharing.":
"This report contains original paths and identifiers. Review them before sharing.",
"Partial Usage results are available as JSON so their coverage and reasons stay with the rows.":
"Partial Usage results are available as JSON so their coverage and reasons stay with the rows.",
"Report format": "Report format",
"Diagnostic report": "Diagnostic report",
"Copy report": "Copy report",
"Save report": "Save report",
"The inspection could not finish. Close this dialogue and try again after database startup.":
"The inspection could not finish. Close this dialogue and try again after database startup.",
} as const;
export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages;
@@ -1297,6 +1297,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "CouchDB 连接调优",
"zh-tw": "CouchDB 連線調校",
},
"CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1.":
{
def: "CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1.",
},
"CouchDB validates the database name when you connect. The name must not be empty.": {
def: "CouchDB validates the database name when you connect. The name must not be empty.",
es: "CouchDB valida el nombre de la base de datos al conectar. El nombre no puede estar vacío.",
@@ -3871,6 +3875,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "稍后",
"zh-tw": "稍後",
},
"Legacy path identity": {
def: "Legacy path identity",
},
"Limit: {datetime} ({timestamp})": {
def: "Limit: {datetime} ({timestamp})",
es: "Límite: {datetime} ({timestamp})",
@@ -7937,6 +7944,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "路径",
"zh-tw": "路徑",
},
"Path identity mode": {
def: "Path identity mode",
},
"Path Obfuscation": {
def: "Path Obfuscation",
es: "Ofuscación de rutas",
@@ -13774,6 +13784,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "输入服务器信息",
"zh-tw": "選擇同步遠端",
},
"Unicode NFC path identity": {
def: "Unicode NFC path identity",
},
"Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": {
def: "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.",
es: "Nombre único entre dispositivos sincronizados. Para editarlo, desactive sincronización de personalización",
+4
View File
@@ -151,6 +151,7 @@
"Copy Report to clipboard": "Copy Report to clipboard",
"CouchDB Configuration": "CouchDB Configuration",
"CouchDB Connection Tweak": "CouchDB Connection Tweak",
"CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1.": "CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1.",
"CouchDB validates the database name when you connect. The name must not be empty.": "CouchDB validates the database name when you connect. The name must not be empty.",
"Create or connect to database and continue": "Create or connect to database and continue",
"Create P2P remote": "Create P2P remote",
@@ -446,6 +447,7 @@
"lang-zh-tw": "繁體中文",
"Last tweak modified timestamp": "Last tweak modified timestamp",
"Later": "Later",
"Legacy path identity": "Legacy path identity",
"Limit: {datetime} ({timestamp})": "Limit: {datetime} ({timestamp})",
"live": "live",
"LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.": "LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.",
@@ -841,6 +843,7 @@
"Paste a connection string": "Paste a connection string",
"Paste the Setup URI generated from one of your active devices.": "Paste the Setup URI generated from one of your active devices.",
"Path": "Path",
"Path identity mode": "Path identity mode",
"Path Obfuscation": "Path Obfuscation",
"Patterns to match files for overwriting instead of merging": "Patterns to match files for overwriting instead of merging",
"Patterns to match files for syncing": "Patterns to match files for syncing",
@@ -1523,6 +1526,7 @@
"Ui.SetupWizard.SetupRemote.ProceedCouchDb": "Continue to CouchDB setup",
"Ui.SetupWizard.SetupRemote.ProceedP2P": "Continue to P2P setup",
"Ui.SetupWizard.SetupRemote.Title": "Choose a synchronisation remote",
"Unicode NFC path identity": "Unicode NFC path identity",
"Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.",
"Unless you are certain, selecting this options is bit dangerous. It assumes that the server configuration is compatible with this device. If this is not the case, data loss may occur. Please ensure you know what you are doing.": "Unless you are certain, selecting this options is bit dangerous. It assumes that the server configuration is compatible with this device. If this is not the case, data loss may occur. Please ensure you know what you are doing.",
"Updating list...": "Updating list...",
+5
View File
@@ -169,6 +169,8 @@ Copy: Copy
Copy Report to clipboard: Copy Report to clipboard
CouchDB Configuration: CouchDB Configuration
CouchDB Connection Tweak: CouchDB Connection Tweak
"CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1.":
"CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1."
CouchDB validates the database name when you connect. The name must not be empty.:
CouchDB validates the database name when you connect. The name must not be
empty.
@@ -556,6 +558,7 @@ lang-ru: Русский
lang-zh: 简体中文
lang-zh-tw: 繁體中文
Later: Later
Legacy path identity: Legacy path identity
"Limit: {datetime} ({timestamp})": "Limit: {datetime} ({timestamp})"
live: live
LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.:
@@ -1251,6 +1254,7 @@ password: password
Password: Password
Paste a connection string: Paste a connection string
Path: Path
Path identity mode: Path identity mode
Path Obfuscation: Path Obfuscation
Patterns to match files for overwriting instead of merging: Patterns to match files for overwriting instead of merging
Patterns to match files for syncing: Patterns to match files for syncing
@@ -2042,6 +2046,7 @@ TweakMismatchResolve:
TweakResolving: Configuration Mismatch Detected
UseRemoteConfig: Use Remote Configuration
AutoAcceptCompatible: Auto-Accept Available
Unicode NFC path identity: Unicode NFC path identity
Unique name between all synchronized devices. To edit this setting, please disable customization sync once.:
Unique name between all synchronized devices. To edit this setting, please
disable customization sync once.
+10 -8
View File
@@ -1,5 +1,6 @@
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { usesEncryptedInternalMetadata } from "@vrtmrz/livesync-commonlib/replication";
import { getPathNormalizationVersion } from "@vrtmrz/livesync-commonlib/settings";
type EndpointProjection = readonly [kind: "url" | "invalid-url", value: string];
@@ -44,17 +45,18 @@ function projectHeaders(value: string): readonly (readonly [name: string, value:
function projectRemoteSecurity(settings: RemoteDBSettings) {
return [
getPathNormalizationVersion(settings),
settings.idDerivationVersion,
settings.idDerivationKey,
settings.encrypt
? ([
"encrypted",
settings.passphrase,
settings.useDynamicIterationCount,
settings.E2EEAlgorithm,
settings.permitEmptyPassphrase,
] as const)
: (["plain"] as const),
? ([
"encrypted",
settings.passphrase,
settings.useDynamicIterationCount,
settings.E2EEAlgorithm,
settings.permitEmptyPassphrase,
] as const)
: (["plain"] as const),
] as const;
}
@@ -7,6 +7,30 @@ import {
} from "./replicatorConfigurationIdentity";
describe("active Replicator configuration identity", () => {
it("replaces a connection when the path identity mode changes", () => {
const previous = configuredSettings({ pathNormalizationVersion: 0 });
const next = { ...previous, pathNormalizationVersion: 1 as const };
expect(getCouchDBReplicatorConfigurationIdentity(next)).not.toBe(
getCouchDBReplicatorConfigurationIdentity(previous)
);
expect(getObjectStorageReplicatorConfigurationIdentity(next)).not.toBe(
getObjectStorageReplicatorConfigurationIdentity(previous)
);
});
it("treats a missing path identity mode as version 0", () => {
const missingVersion = configuredSettings();
Reflect.deleteProperty(missingVersion, "pathNormalizationVersion");
const explicitVersion0 = { ...missingVersion, pathNormalizationVersion: 0 as const };
expect(getCouchDBReplicatorConfigurationIdentity(explicitVersion0)).toBe(
getCouchDBReplicatorConfigurationIdentity(missingVersion)
);
expect(getObjectStorageReplicatorConfigurationIdentity(explicitVersion0)).toBe(
getObjectStorageReplicatorConfigurationIdentity(missingVersion)
);
});
function configuredSettings(overrides: Partial<ObsidianLiveSyncSettings> = {}): ObsidianLiveSyncSettings {
return Object.assign(createNewVaultSettings(), {
activeConfigurationId: "profile-a",
+13
View File
@@ -28,7 +28,10 @@ import {
getObjectStorageReplicatorConfigurationIdentity,
} from "./replicatorConfigurationIdentity";
import {
createCouchDBJoinedDevicesResourceFactory,
createObjectStorageJoinedDevicesResourceFactory,
createCouchDBConnectionProbeFactory,
createCouchDBInitialFetchFastForwardResourceFactory,
createCouchDBPreferredTweakProbeFactory,
createCouchDBSecuritySeedResourceFactory,
createCouchDBSynchronisationInformationResourceFactory,
@@ -115,6 +118,9 @@ export function createCentralReplicatorProviderDefinitions(
configurationIdentity: getCouchDBReplicatorConfigurationIdentity,
create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)),
remoteResources: {
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
createCouchDBJoinedDevicesResourceFactory(host)
),
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)),
[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability(
createCouchDBPreferredTweakProbeFactory(host)
@@ -125,6 +131,9 @@ export function createCentralReplicatorProviderDefinitions(
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: supportedCapability(
createCouchDBSynchronisationInformationResourceFactory(host)
),
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: supportedCapability(
createCouchDBInitialFetchFastForwardResourceFactory(host)
),
},
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot),
@@ -141,6 +150,9 @@ export function createCentralReplicatorProviderDefinitions(
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
create: () => Promise.resolve(new LiveSyncJournalReplicator(host)),
remoteResources: {
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
createObjectStorageJoinedDevicesResourceFactory(host)
),
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(
createObjectStorageConnectionProbeFactory(host)
),
@@ -151,6 +163,7 @@ export function createCentralReplicatorProviderDefinitions(
createObjectStorageSecuritySeedResourceFactory(host)
),
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE,
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: CAPABILITY_NOT_APPLICABLE,
},
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot),
+21 -3
View File
@@ -44,7 +44,16 @@ describe("central Replicator provider definitions", () => {
it("keeps the retained remote-resource catalogue bounded", () => {
expect
.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", () => {
@@ -106,18 +115,27 @@ describe("central Replicator provider definitions", () => {
const couchAdministration = definitions.get(REMOTE_COUCHDB)?.centralRemoteAdministration;
const objectAdministration = definitions.get(REMOTE_MINIO)?.centralRemoteAdministration;
expect(Object.keys(couchResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
expect(Object.keys(objectResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
const resources = Object.values(REMOTE_RESOURCE_KINDS).sort();
expect(Object.keys(couchResources ?? {}).sort()).toEqual(resources);
expect(Object.keys(objectResources ?? {}).sort()).toEqual(resources);
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.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.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
CAPABILITY_SUPPORT_KINDS.SUPPORTED
);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]?.kind).toBe(
CAPABILITY_SUPPORT_KINDS.SUPPORTED
);
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
);
expect(objectResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]?.kind).toBe(
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
);
expect(couchAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(objectAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect("activeRemoteReads" in definitions.get(REMOTE_COUCHDB)!).toBe(false);
@@ -5,6 +5,7 @@ import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
const mocks = vi.hoisted(() => ({
logger: vi.fn(),
initialFetchFastForwardResource: undefined as unknown,
couchDB: [] as Array<{
host: unknown;
isMobile: ReturnType<typeof vi.fn>;
@@ -12,6 +13,9 @@ const mocks = vi.hoisted(() => ({
getRemoteStatus: ReturnType<typeof vi.fn>;
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
getJoinedDevices: ReturnType<typeof vi.fn>;
forgetJoinedDevice: ReturnType<typeof vi.fn>;
createInitialFetchFastForwardResource: ReturnType<typeof vi.fn>;
closeReplication: ReturnType<typeof vi.fn>;
}>,
objectStorage: [] as Array<{
@@ -20,6 +24,8 @@ const mocks = vi.hoisted(() => ({
getRemoteStatus: ReturnType<typeof vi.fn>;
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
getJoinedDevices: ReturnType<typeof vi.fn>;
forgetJoinedDevice: ReturnType<typeof vi.fn>;
closeReplication: ReturnType<typeof vi.fn>;
}>,
checkSyncInfo: vi.fn(async () => true),
@@ -42,6 +48,9 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicato
getRemoteStatus = vi.fn();
getRemotePreferredTweakValues = vi.fn();
getReplicationPBKDF2Salt = vi.fn();
getJoinedDevices = vi.fn();
forgetJoinedDevice = vi.fn();
createInitialFetchFastForwardResource = vi.fn(() => mocks.initialFetchFastForwardResource);
closeReplication = vi.fn();
constructor(host: unknown) {
@@ -58,6 +67,8 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
getRemoteStatus = vi.fn();
getRemotePreferredTweakValues = vi.fn();
getReplicationPBKDF2Salt = vi.fn();
getJoinedDevices = vi.fn();
forgetJoinedDevice = vi.fn();
closeReplication = vi.fn();
constructor(host: unknown) {
@@ -69,10 +80,13 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
import {
createCouchDBConnectionProbeFactory,
createCouchDBJoinedDevicesResourceFactory,
createCouchDBInitialFetchFastForwardResourceFactory,
createCouchDBPreferredTweakProbeFactory,
createCouchDBSecuritySeedResourceFactory,
createCouchDBSynchronisationInformationResourceFactory,
createObjectStorageConnectionProbeFactory,
createObjectStorageJoinedDevicesResourceFactory,
createObjectStoragePreferredTweakProbeFactory,
createObjectStorageSecuritySeedResourceFactory,
} from "./replicatorResources";
@@ -92,6 +106,7 @@ describe("replicator probe factories", () => {
beforeEach(() => {
mocks.couchDB.length = 0;
mocks.objectStorage.length = 0;
mocks.initialFetchFastForwardResource = undefined;
mocks.checkSyncInfo.mockReset().mockResolvedValue(true);
mocks.logger.mockClear();
});
@@ -226,6 +241,65 @@ describe("replicator probe factories", () => {
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 () => {
const couchProbe = await createCouchDBPreferredTweakProbeFactory({} as never)(createSettings());
const objectProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)(
@@ -370,4 +444,29 @@ describe("replicator probe factories", () => {
await resource.dispose();
expect(replicator.closeReplication).toHaveBeenCalledOnce();
});
it("creates an Initial Fetch Fast Forward resource from a settings snapshot and leaves ownership to the resource", async () => {
const host = { name: "host" };
const settings = createSettings();
const snapshot = { ...settings };
const prepare = vi.fn(async () => undefined);
const dispose = vi.fn(async () => undefined);
const resource = { prepare, dispose };
const factory = createCouchDBInitialFetchFastForwardResourceFactory(host as never);
mocks.initialFetchFastForwardResource = resource;
const result = await factory(settings);
const replicator = mocks.couchDB[0];
settings.couchDB_URI = "https://changed.example.test";
expect(replicator.createInitialFetchFastForwardResource).toHaveBeenCalledWith(snapshot);
expect(replicator.createInitialFetchFastForwardResource.mock.calls[0][0]).not.toBe(settings);
expect(replicator.host).toBe(host);
expect(result).toBe(resource);
await expect(result.prepare()).resolves.toBeUndefined();
await result.dispose();
expect(prepare).toHaveBeenCalledOnce();
expect(dispose).toHaveBeenCalledOnce();
});
});
+6
View File
@@ -14,3 +14,9 @@ export {
type SecuritySeedResourceHost,
} from "./securitySeed";
export { createCouchDBSynchronisationInformationResourceFactory } from "./synchronisationInformation";
export {
createCouchDBJoinedDevicesResourceFactory,
createObjectStorageJoinedDevicesResourceFactory,
} from "./joinedDevices";
export { createCouchDBInitialFetchFastForwardResourceFactory } from "./initialFetchFastForward";
@@ -0,0 +1,17 @@
import type { InitialFetchFastForwardResourceFactory } from "@vrtmrz/livesync-commonlib/replication";
import {
LiveSyncCouchDBReplicator,
type LiveSyncCouchDBReplicatorEnv,
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { snapshotRemoteSettings } from "./shared";
/** Create a private CouchDB Replicator for a fresh Fast Fetch checkpoint handoff. */
export function createCouchDBInitialFetchFastForwardResourceFactory(
host: LiveSyncCouchDBReplicatorEnv
): InitialFetchFastForwardResourceFactory {
return (setting) => {
const snapshot = snapshotRemoteSettings(setting);
const replicator = new LiveSyncCouchDBReplicator(host);
return Promise.resolve(replicator.createInitialFetchFastForwardResource(snapshot));
};
}
@@ -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 {
DEFAULT_SETTINGS,
@@ -18,17 +19,7 @@ import {
} from "./turnSettingsPrivacy";
class MemorySettingService extends SettingService {
readonly items = new Map<string, string>();
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) {
this.saved = structuredClone(settings);
return Promise.resolve();
@@ -70,6 +61,7 @@ describe("managed TURN settings privacy", () => {
const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current });
current.remoteConfigurations.managed.uri = originalURI;
const service = new MemorySettingService(new ServiceContext(), {
deviceValues: new MemoryValueStore(),
APIService: {
getSystemVaultName: () => "test-vault",
getAppID: () => "test-app",
@@ -0,0 +1,298 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
addIcon: vi.fn(),
diff_match_patch: class DiffMatchPatch {},
normalizePath: (path: string) => path,
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("./PluginDialogModal.ts", () => ({ PluginDialogModal: class PluginDialogModal {} }));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
ConflictResolveModal: class ConflictResolveModal {},
}));
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
vi.mock("@/common/utils.ts", () => ({
cancelTask: vi.fn(),
EVEN: Symbol("even"),
isCustomisationSyncMetadata: vi.fn(),
isPluginMetadata: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({ PeriodicProcessor: class PeriodicProcessor {} }));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
eventHub: { onEvent: vi.fn() },
}));
vi.mock("@/common/translation", () => ({ $msg: (message: string) => message }));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ getObsidianCommunityPluginManager: vi.fn() }));
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
import { ConfigSync, PluginDataExDisplayV2, pluginManifests } from "./CmdConfigSync";
const filenames = ["main.js", "manifest.json", "styles.css"];
const content: Record<string, string> = {
"main.js": "module.exports = class ExamplePlugin {};",
"manifest.json": JSON.stringify({ id: "example", name: "Example", version: "1.0.0" }),
"styles.css": ".example { color: red; }",
};
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
async function catalogue() {
const fixture = await createOptionalFileSyncDatabase();
fixtureOwners.push(fixture);
fixture.settings.syncOnlyRegEx = "\\.(?:md|png|pdf)$" as typeof fixture.settings.syncOnlyRegEx;
const paths = filenames.map((name) => `ix:device/PLUGIN_MAIN/example%${name}` as FilePathWithPrefix);
for (const [index, path] of paths.entries()) {
const wireContent = `fixture-header\u200b\n\u200c${btoa(content[filenames[index]])}`;
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(path, wireContent))).resolves.toMatchObject({
ok: true,
});
}
const gates = new Map(paths.map((path) => [path, deferred()]));
const readEntry = fixture.localDatabase.getDBEntry.bind(fixture.localDatabase);
vi.spyOn(fixture.localDatabase, "getDBEntry").mockImplementation(async (path, ...args) => {
await gates.get(path)?.promise;
return readEntry(path, ...args);
});
const written = new Map<string, string>();
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
Object.assign(configSync, {
core: {
localDatabase: fixture.localDatabase,
settings: fixture.settings,
services: fixture.services,
storageAccess: {
ensureDir: vi.fn(async () => true),
readHiddenFileText: vi.fn(async (path: string) => written.get(path) ?? ""),
writeHiddenFileAuto: vi.fn(async (path: string, data: string) => {
written.set(path, data);
}),
},
},
pluginList: [],
pluginListGeneration: 0,
loadedManifest_mTime: new Map(),
updatingV2Count: 0,
pluginScanProcessor: { clearQueue: vi.fn(), enqueue: vi.fn() },
_log: vi.fn(),
storeCustomisationFileV2: vi.fn(async () => true),
});
const updates: Promise<void>[] = [];
const update = configSync.updatePluginListV2.bind(configSync);
vi.spyOn(configSync, "updatePluginListV2").mockImplementation((...args) => {
const promise = update(...args);
updates.push(promise);
return promise;
});
const release = (order = filenames) => {
for (const name of order) gates.get(`ix:device/PLUGIN_MAIN/example%${name}` as FilePathWithPrefix)!.resolve();
};
return { ...fixture, configSync, updates, paths, release, written };
}
afterEach(async () => {
vi.restoreAllMocks();
pluginManifests.clear();
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
});
describe("issue #1271: rebuilding a multi-file Customisation Sync item", () => {
it.each([
{ order: filenames },
{ order: [...filenames].reverse() },
{ order: ["manifest.json", "main.js", "styles.css"] },
])("retains all per-file documents when reads are released as $order", async ({ order }) => {
const fixture = await catalogue();
await fixture.configSync.reloadPluginList(false);
fixture.release(order);
await Promise.all(fixture.updates);
const documents = await fixture.database.allDocs({ keys: fixture.paths });
expect(documents.rows).toHaveLength(3);
expect(fixture.configSync.pluginList).toHaveLength(1);
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
filenames.map((name) => `plugins/example/${name}`).sort()
);
});
it("applies all three files after the first reload", async () => {
const fixture = await catalogue();
await fixture.configSync.reloadPluginList(false);
fixture.release();
await Promise.all(fixture.updates);
await expect(
fixture.configSync.applyDataV2(fixture.configSync.pluginList[0] as PluginDataExDisplayV2)
).resolves.toBe(true);
expect([...fixture.written.keys()].sort()).toEqual(
filenames.map((name) => `.obsidian/plugins/example/${name}`).sort()
);
for (const name of filenames)
expect(fixture.written.get(`.obsidian/plugins/example/${name}`)).toBe(content[name]);
});
it("fills an existing row during a second enumeration", async () => {
const fixture = await catalogue();
await fixture.configSync.reloadPluginList(false);
fixture.release();
await Promise.all(fixture.updates);
await fixture.configSync.updatePluginList(false);
await Promise.all(fixture.updates);
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
filenames.map((name) => `plugins/example/${name}`).sort()
);
});
it("retains all files when item updates run sequentially", async () => {
const fixture = await catalogue();
fixture.release();
for (const path of fixture.paths) await fixture.configSync.updatePluginListV2(false, path);
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
filenames.map((name) => `plugins/example/${name}`).sort()
);
});
it("updates a different item while another item's reads are pending", async () => {
const fixture = await catalogue();
const otherPath = "ix:device/PLUGIN_MAIN/other%main.js" as FilePathWithPrefix;
await fixture.localDatabase.putDBEntry(
optionalFileEntry(otherPath, `fixture-header\u200b\n\u200c${btoa(content["main.js"])}`)
);
const pending = fixture.paths.map((path) => fixture.configSync.updatePluginListV2(false, path));
try {
await fixture.configSync.updatePluginListV2(false, otherPath);
expect(fixture.configSync.pluginList.map((entry) => entry.name)).toEqual(["other"]);
expect(fixture.configSync.updatingV2Count).toBe(3);
} finally {
fixture.release();
await Promise.all(pending);
}
expect(fixture.configSync.updatingV2Count).toBe(0);
});
it("continues updating an item after an earlier read fails", async () => {
const fixture = await catalogue();
fixture.release();
vi.spyOn(fixture.configSync, "createPluginDataExFileV2").mockRejectedValueOnce(new Error("Read failed"));
const failed = fixture.configSync.updatePluginListV2(false, fixture.paths[0]);
const next = fixture.configSync.updatePluginListV2(false, fixture.paths[1]);
await expect(failed).rejects.toThrow("Read failed");
await next;
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename)).toEqual([
"plugins/example/manifest.json",
]);
expect(fixture.configSync.updatingV2Count).toBe(0);
});
it.each(["read", "file update"])("reload discards cached files while an earlier %s is pending", async (phase) => {
const fixture = await catalogue();
fixture.release();
for (const path of fixture.paths) await fixture.configSync.updatePluginListV2(false, path);
const earlierRead = deferred();
const freshRead = deferred();
const entered = deferred();
const createFile = fixture.configSync.createPluginDataExFileV2.bind(fixture.configSync);
let reads = 0;
vi.spyOn(fixture.configSync, "createPluginDataExFileV2").mockImplementation(async (path) => {
if (reads++ === 0) {
if (phase === "read") {
entered.resolve();
await earlierRead.promise;
}
} else {
await freshRead.promise;
}
return createFile(path);
});
if (phase === "file update") {
const entry = fixture.configSync.pluginList[0] as PluginDataExDisplayV2;
const setFile = entry.setFile.bind(entry);
vi.spyOn(entry, "setFile").mockImplementationOnce(async (file) => {
entered.resolve();
await earlierRead.promise;
await setFile(file);
});
}
const pending = fixture.configSync.updatePluginListV2(false, fixture.paths[0]);
try {
await entered.promise;
for (const path of fixture.paths.slice(1)) await fixture.database.remove(await fixture.database.get(path));
await fixture.configSync.reloadPluginList(false);
earlierRead.resolve();
await pending;
} finally {
earlierRead.resolve();
freshRead.resolve();
await Promise.all(fixture.updates);
}
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename)).toEqual([
"plugins/example/main.js",
]);
});
it("clearing the disabled feature prevents pending updates from repopulating the list", async () => {
const fixture = await catalogue();
const pending = fixture.paths.map((path) => fixture.configSync.updatePluginListV2(false, path));
fixture.settings.usePluginSync = false;
await fixture.configSync.updatePluginList(false);
fixture.release();
await Promise.all(pending);
expect(fixture.configSync.pluginList).toEqual([]);
expect(fixture.configSync.updatingV2Count).toBe(0);
});
it("a superseded enumeration cannot reintroduce a deleted item", async () => {
const fixture = await catalogue();
const obsoletePath = "ix:device/PLUGIN_MAIN/obsolete%main.js" as FilePathWithPrefix;
await fixture.localDatabase.putDBEntry(
optionalFileEntry(obsoletePath, `fixture-header\u200b\n\u200c${btoa(content["main.js"])}`)
);
const enumerate = fixture.localDatabase.findEntries.bind(fixture.localDatabase);
type EnumeratedEntry = ReturnType<typeof enumerate> extends AsyncGenerator<infer Entry> ? Entry : never;
const obsoleteDocs: EnumeratedEntry[] = [];
for await (const doc of enumerate(obsoletePath, `${obsoletePath}\u{10ffff}`, { include_docs: true })) {
obsoleteDocs.push(doc);
}
const entered = deferred();
const release = deferred();
let scans = 0;
vi.spyOn(fixture.localDatabase, "findEntries").mockImplementation((...args) => {
if (scans++ !== 0) return enumerate(...args);
return (async function* () {
entered.resolve();
await release.promise;
yield* obsoleteDocs;
})();
});
const pending = fixture.configSync.updatePluginList(false);
try {
await entered.promise;
await fixture.database.remove(await fixture.database.get(obsoletePath));
await fixture.configSync.reloadPluginList(false);
} finally {
release.resolve();
fixture.release();
await pending;
await Promise.all(fixture.updates);
}
expect(fixture.configSync.pluginList.map((entry) => entry.name)).toEqual(["example"]);
expect(fixture.configSync.pluginList[0].files).toHaveLength(3);
expect(fixture.configSync.updatingV2Count).toBe(0);
});
});
@@ -0,0 +1,220 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
addIcon: vi.fn(),
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("./PluginDialogModal.ts", () => ({
PluginDialogModal: class PluginDialogModal {},
}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
JsonResolveModal: class JsonResolveModal {},
}));
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
ConflictResolveModal: class ConflictResolveModal {},
}));
// The readiness helpers come from the real LiveSyncCommands, so only its log view dependency is replaced.
vi.mock("@/modules/features/ModuleLog.ts", () => ({
MARK_DONE: "",
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
PERIODIC_PLUGIN_SWEEP: 60,
}));
vi.mock("@/common/utils.ts", () => ({
cancelTask: vi.fn(),
EVEN: Symbol("even"),
isCustomisationSyncMetadata: vi.fn(),
isPluginMetadata: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {},
}));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
eventHub: {
onEvent: vi.fn(),
},
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { getDocData } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { ConfigSync } from "./CmdConfigSync";
import { createOptionalFileSyncDatabase } from "../../../test/fixtures/optionalFileSyncDatabase";
const path = ".obsidian/snippets/test.css" as FilePath;
const fixtures: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
async function createConfigSync(useV2 = false) {
const fixture = await createOptionalFileSyncDatabase();
fixtures.push(fixture);
fixture.settings.usePluginSyncV2 = useV2;
const files = new Map<string, { content: string; mtime: number }>([
[path, { content: "body { color: red; }", mtime: 2000 }],
]);
const storageAccess = {
statHidden: vi.fn(async (file: string) => {
const entry = files.get(file);
return entry
? { type: "file" as const, ctime: 1000, mtime: entry.mtime, size: entry.content.length }
: null;
}),
readHiddenFileBinary: vi.fn(async (file: string) => {
const entry = files.get(file);
if (!entry) throw new Error(`Missing fixture: ${file}`);
return new TextEncoder().encode(entry.content).buffer;
}),
};
const services = {
...fixture.services,
appLifecycle: { isReady: () => false, isSuspended: () => true },
database: { ...fixture.services.database, isDatabaseReady: vi.fn(() => true) },
path: {
...fixture.services.path,
isMarkedAsSameChanges: vi.fn(),
markChangesAreSame: vi.fn(),
},
};
const config = Object.create(ConfigSync.prototype) as ConfigSync;
Object.assign(config, {
core: { settings: fixture.settings, localDatabase: fixture.localDatabase, storageAccess, services },
_log: vi.fn(),
updatePluginList: vi.fn(async () => undefined),
updatePluginListV2: vi.fn(async () => undefined),
recentProcessedInternalFiles: [],
});
const databasePath = useV2 ? config.filenameWithUnifiedKey(path) : config.filenameToUnifiedKey(path);
return { fixture, config, files, services, storageAccess, databasePath };
}
afterEach(async () => {
vi.restoreAllMocks();
for (const fixture of fixtures.splice(0)) await fixture.dispose();
});
describe("Customisation Sync optional save completion", () => {
const flush = { whenFlushRequested: Promise.resolve() };
it("waits for the database write while the application is suspended or still starting", async () => {
const { fixture, config } = await createConfigSync();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const put = fixture.localDatabase.putDBEntry.bind(fixture.localDatabase);
const spy = vi.spyOn(fixture.localDatabase, "putDBEntry").mockImplementation(async (...args) => {
await gate;
return await put(...args);
});
let completed = false;
const result = config._processOptionalFileEventWithResult(path, flush).then((outcome) => {
completed = true;
return outcome;
});
await vi.waitFor(() => expect(spy).toHaveBeenCalledOnce());
expect(completed).toBe(false);
release();
await expect(result).resolves.toEqual({ kind: "completed" });
});
it.each([false, true])("confirms identical contents and stores a same-time edit (V2: %s)", async (useV2) => {
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
const first = await fixture.database.get(databasePath);
await config._processOptionalFileEventWithResult(path, flush);
expect((await fixture.database.get(databasePath))._rev).toBe(first._rev);
files.get(path)!.content = "body { color: tan; }";
await config._processOptionalFileEventWithResult(path, flush);
expect((await fixture.database.get(databasePath))._rev).not.toBe(first._rev);
});
it.each([
{ useV2: false, throws: false },
{ useV2: true, throws: false },
{ useV2: false, throws: true },
{ useV2: true, throws: true },
])("rejects a failed write and permits the unchanged notification to retry: %j", async ({ useV2, throws }) => {
const { fixture, config, databasePath } = await createConfigSync(useV2);
const failure = new Error("temporary write failure");
const put = vi.spyOn(fixture.localDatabase, "putDBEntry");
if (throws) put.mockRejectedValueOnce(failure);
else put.mockResolvedValueOnce(false);
const failed = config._processOptionalFileEventWithResult(path, flush);
if (throws) await expect(failed).rejects.toBe(failure);
else await expect(failed).rejects.toThrow("Could not save customisation");
await expect(fixture.localDatabase.getDBEntry(databasePath)).resolves.toBe(false);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
expect(put).toHaveBeenCalledTimes(2);
expect(await fixture.localDatabase.getDBEntry(databasePath)).not.toBe(false);
});
it.each([false, true])("confirms deletion and treats a repeated deletion as complete (V2: %s)", async (useV2) => {
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
await config._processOptionalFileEventWithResult(path, flush);
files.delete(path);
const put = vi.spyOn(fixture.localDatabase, "putRaw");
put.mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
await expect(config._processOptionalFileEventWithResult(path, flush)).rejects.toThrow(
"Could not delete customisation"
);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
const deleted = await fixture.database.get(databasePath);
expect("deleted" in deleted && deleted.deleted).toBe(true);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
expect((await fixture.database.get(databasePath))._rev).toBe(deleted._rev);
});
it("distinguishes a different owner and unmet prerequisites", async () => {
const { config, services, storageAccess } = await createConfigSync();
await expect(config._processOptionalFileEventWithResult("notes.md" as FilePath, flush)).resolves.toEqual({
kind: "not-applicable",
});
services.database.isDatabaseReady.mockReturnValue(false);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
kind: "veto",
value: "database-not-ready",
});
services.database.isDatabaseReady.mockReturnValue(true);
vi.spyOn(services.setting, "getDeviceAndVaultName").mockReturnValue("");
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
kind: "veto",
value: "device-name-not-configured",
});
expect(storageAccess.readHiddenFileBinary).not.toHaveBeenCalled();
});
it("preserves every V2 file when neighbouring plug-in files arrive together", async () => {
const { fixture, config, files } = await createConfigSync(true);
const main = ".obsidian/plugins/example/main.js" as FilePath;
const style = ".obsidian/plugins/example/styles.css" as FilePath;
files.set(main, { content: "module.exports = {};", mtime: 3000 });
files.set(style, { content: "body {}", mtime: 3000 });
await Promise.all([
config._processOptionalFileEventWithResult(main, flush),
config._processOptionalFileEventWithResult(style, flush),
]);
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(main))).not.toBe(false);
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(style))).not.toBe(false);
});
it("does not acknowledge a database deletion which was not accepted", async () => {
const { fixture, config, databasePath } = await createConfigSync();
await config.storeCustomizationFiles(path);
const original = await fixture.localDatabase.getDBEntry(databasePath);
expect(original).not.toBe(false);
vi.spyOn(fixture.localDatabase, "putRaw").mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
await expect(config.deleteConfigOnDatabase(databasePath)).resolves.toBe(false);
const unchanged = await fixture.localDatabase.getDBEntry(databasePath);
expect(unchanged && getDocData(unchanged.data)).toBe(original && getDocData(original.data));
});
});
+124 -34
View File
@@ -69,6 +69,10 @@ import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import { PendingConfigSaves } from "./PendingConfigSaves";
const d = "\u200b";
const d2 = "\n";
const UPDATED_CONFIGURATION_NOTICE_KEY = "config-sync:updated-configuration";
@@ -412,6 +416,7 @@ export class ConfigSync extends LiveSyncCommands {
periodicPluginSweepProcessor = new PeriodicProcessor(this.core, async () => await this.scanAllConfigFiles(false));
pluginList: IPluginDataExDisplay[] = [];
private pluginListGeneration = 0;
showPluginSyncModal() {
if (!this.isThisModuleEnabled()) {
return;
@@ -532,6 +537,7 @@ export class ConfigSync extends LiveSyncCommands {
return Promise.resolve(true);
}
async reloadPluginList(showMessage: boolean) {
this.pluginListGeneration++;
this.pluginList = [];
this.loadedManifest_mTime.clear();
pluginList.set(this.pluginList);
@@ -761,39 +767,46 @@ export class ConfigSync extends LiveSyncCommands {
updatingV2Count = 0;
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
const generation = this.pluginListGeneration;
const isCurrent = () => generation === this.pluginListGeneration && this.isThisModuleEnabled();
try {
this.updatingV2Count++;
pluginV2Progress.set(this.updatingV2Count);
// const unifiedFilenameWithKey = this.filenameWithUnifiedKey(updatedDocumentPath);
const { pathV1 } = this.parseUnifiedPath(unifiedFilenameWithKey);
const oldEntry = this.pluginList.find((e) => e.documentPath == pathV1);
let entry: PluginDataExDisplayV2 | undefined = undefined;
await serialized(`config:catalogue:${pathV1}`, async () => {
if (!isCurrent()) return;
const oldEntry = this.pluginList.find((e) => e.documentPath == pathV1);
let entry: PluginDataExDisplayV2 | undefined = undefined;
if (!oldEntry || !(oldEntry instanceof PluginDataExDisplayV2)) {
const newEntry = this.createPluginDataFromV2(unifiedFilenameWithKey);
if (newEntry) {
entry = newEntry;
if (!oldEntry || !(oldEntry instanceof PluginDataExDisplayV2)) {
const newEntry = this.createPluginDataFromV2(unifiedFilenameWithKey);
if (newEntry) {
entry = newEntry;
}
} else if (oldEntry instanceof PluginDataExDisplayV2) {
entry = oldEntry;
}
} else if (oldEntry instanceof PluginDataExDisplayV2) {
entry = oldEntry;
}
if (!entry) return;
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
if (file) {
await entry.setFile(file);
} else {
entry.deleteFile(unifiedFilenameWithKey);
if (entry.files.length == 0) {
this.pluginList = this.pluginList.filter((e) => e.documentPath != pathV1);
if (!entry) return;
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
if (!isCurrent()) return;
if (file) {
await entry.setFile(file);
} else {
entry.deleteFile(unifiedFilenameWithKey);
if (entry.files.length == 0) {
this.pluginList = this.pluginList.filter((e) => e.documentPath != pathV1);
}
}
}
const newList = this.pluginList.filter((e) => e.documentPath != entry.documentPath);
newList.push(entry);
this.pluginList = newList;
if (!isCurrent()) return;
const newList = this.pluginList.filter((e) => e.documentPath != entry.documentPath);
newList.push(entry);
this.pluginList = newList;
scheduleTask("updatePluginListV2", 100, () => {
pluginList.set(this.pluginList);
scheduleTask("updatePluginListV2", 100, () => {
pluginList.set(this.pluginList);
});
});
} finally {
this.updatingV2Count--;
@@ -871,11 +884,13 @@ export class ConfigSync extends LiveSyncCommands {
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
if (!this.isThisModuleEnabled()) {
this.pluginListGeneration++;
this.pluginScanProcessor.clearQueue();
this.pluginList = [];
pluginList.set(this.pluginList);
return;
}
const generation = this.pluginListGeneration;
try {
this.updatingV2Count++;
pluginV2Progress.set(this.updatingV2Count);
@@ -888,6 +903,7 @@ export class ConfigSync extends LiveSyncCommands {
})
: this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
for await (const v of plugins) {
if (generation !== this.pluginListGeneration || !this.isThisModuleEnabled()) return;
if (v.deleted || v._deleted) continue;
if (v.path.indexOf("%") !== -1) {
fireAndForget(() => this.updatePluginListV2(showMessage, v.path));
@@ -1206,7 +1222,7 @@ export class ConfigSync extends LiveSyncCommands {
}
recentProcessedInternalFiles = [] as string[];
async makeEntryFromFile(path: FilePath): Promise<false | PluginDataExFile> {
async makeEntryFromFile(path: FilePath, requireCompletion = false): Promise<false | PluginDataExFile> {
const stat = await this.core.storageAccess.statHidden(path);
let version: string | undefined;
let displayName: string | undefined;
@@ -1240,6 +1256,7 @@ export class ConfigSync extends LiveSyncCommands {
} catch (ex) {
this._log(`The file ${path} could not be encoded`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false;
}
const mtime = stat.mtime;
@@ -1254,6 +1271,15 @@ export class ConfigSync extends LiveSyncCommands {
}
async storeCustomisationFileV2(path: FilePath, term: string, force = false) {
return await this.storeCustomisationFileV2Core(path, term, force, false);
}
private async storeCustomisationFileV2Core(
path: FilePath,
term: string,
force: boolean,
requireCompletion: boolean
) {
const vf = this.filenameWithUnifiedKey(path, term);
return await serialized(`plugin-${vf}`, async () => {
const prefixedFileName = vf;
@@ -1261,7 +1287,8 @@ export class ConfigSync extends LiveSyncCommands {
const id = await this.path2id(prefixedFileName);
const stat = await this.core.storageAccess.statHidden(path);
if (!stat) {
return false;
if (!requireCompletion) return false;
return await this.deleteConfigOnDatabaseCore(vf, force, true);
}
const mtime = stat.mtime;
const content = await this.core.storageAccess.readHiddenFileBinary(path);
@@ -1285,7 +1312,10 @@ export class ConfigSync extends LiveSyncCommands {
eden: {},
};
} else {
if (this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN) {
if (
!requireCompletion &&
this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN
) {
this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Already checked the same)`,
LOG_LEVEL_DEBUG
@@ -1300,7 +1330,7 @@ export class ConfigSync extends LiveSyncCommands {
const dataStart = dataSrc.indexOf(DUMMY_END);
const oldContent = dataSrc.substring(dataStart + DUMMY_END.length);
const oldContentArray = base64ToArrayBuffer(oldContent);
if (await isDocContentSame(oldContentArray, content)) {
if (!old.deleted && (await isDocContentSame(oldContentArray, content))) {
this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (the same content)`,
LOG_LEVEL_VERBOSE
@@ -1320,24 +1350,36 @@ export class ConfigSync extends LiveSyncCommands {
};
}
const ret = await this.localDatabase.putDBEntry(saveData);
if (requireCompletion && (!ret || !ret.ok))
throw new LiveSyncError(`Could not save customisation: ${path}`);
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
fireAndForget(() => this.updatePluginListV2(false, this.filenameWithUnifiedKey(path)));
return ret;
} catch (ex) {
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false;
}
});
}
async storeCustomizationFiles(path: FilePath, termOverRide?: string) {
return await this.storeCustomizationFilesCore(path, termOverRide, false);
}
private async storeCustomizationFilesCore(
path: FilePath,
termOverRide: string | undefined,
requireCompletion: boolean
) {
const term = termOverRide || this.services.setting.getDeviceAndVaultName();
if (term == "") {
this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE);
if (requireCompletion) throw new LiveSyncError("The device name is not configured");
return;
}
if (this.useV2) {
return await this.storeCustomisationFileV2(path, term);
return await this.storeCustomisationFileV2Core(path, term, false, requireCompletion);
}
const vf = this.filenameToUnifiedKey(path, term);
// console.warn(`Storing ${path} to ${bareVF} :--> ${keyedVF}`);
@@ -1378,7 +1420,7 @@ export class ConfigSync extends LiveSyncCommands {
fileTargets = ["manifest.json", "theme.css"].map((e) => `${parentPath}/${e}` as FilePath);
}
for (const target of fileTargets) {
const data = await this.makeEntryFromFile(target);
const data = await this.makeEntryFromFile(target, requireCompletion);
if (data == false) {
this._log(`Config: skipped (Possibly is not exist): ${target} `, LOG_LEVEL_VERBOSE);
continue;
@@ -1398,9 +1440,9 @@ export class ConfigSync extends LiveSyncCommands {
// this._log(`Configuration saving: ${prefixedFileName}`);
if (dt.files.length == 0) {
this._log(`Nothing left: deleting.. ${path}`);
await this.deleteConfigOnDatabase(prefixedFileName);
const deleted = await this.deleteConfigOnDatabaseCore(prefixedFileName, false, requireCompletion);
await this.updatePluginList(false, prefixedFileName);
return;
return requireCompletion ? deleted : undefined;
}
const content = createTextBlob(serialize(dt));
@@ -1422,7 +1464,7 @@ export class ConfigSync extends LiveSyncCommands {
eden: {},
};
} else {
if (old.mtime == mtime) {
if (!requireCompletion && old.mtime == mtime) {
// this._log(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same time)`, LOG_LEVEL_VERBOSE);
return true;
}
@@ -1443,7 +1485,7 @@ export class ConfigSync extends LiveSyncCommands {
}
});
const isSame = (await Promise.all(diffs)).every((e) => e == true);
if (isSame) {
if (isSame && !old.deleted) {
this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same content)`,
LOG_LEVEL_VERBOSE
@@ -1464,16 +1506,51 @@ export class ConfigSync extends LiveSyncCommands {
};
}
const ret = await this.localDatabase.putDBEntry(saveData);
if (requireCompletion && (!ret || !ret.ok))
throw new LiveSyncError(`Could not save customisation: ${path}`);
await this.updatePluginList(false, saveData.path);
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
return ret;
} catch (ex) {
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false;
}
});
}
private pendingOptionalSaves?: PendingConfigSaves;
async _processOptionalFileEventWithResult(
path: FilePath,
options?: OptionalFileEventOptions
): Promise<HandlerOutcome<void, string>> {
if (!this.isThisModuleEnabled() || !this.isTargetPath(path)) return { kind: "not-applicable" };
const configDir = normalizePath(this.configDir);
const handledElsewhere = Object.values(this.settings.pluginSyncExtendedSetting)
.filter((entry) => entry.mode !== MODE_SELECTIVE && entry.mode !== MODE_SHINY)
.flatMap((entry) => entry.files)
.some((file) => `${configDir}/${file}`.toLowerCase().startsWith(path.toLowerCase()));
if (handledElsewhere) return { kind: "not-applicable" };
if (!this._isDatabaseReady()) return { kind: "veto", value: "database-not-ready" };
const term = this.services.setting.getDeviceAndVaultName();
if (!term) return { kind: "veto", value: "device-name-not-configured" };
const stat = await this.core.storageAccess.statHidden(path);
if (stat && stat.type !== "file") return { kind: "not-applicable" };
// V1 saves a complete file group; V2 persists each file separately.
const key = this.useV2 ? this.filenameWithUnifiedKey(path, term) : this.filenameToUnifiedKey(path, term);
this.pendingOptionalSaves ??= new PendingConfigSaves();
await this.pendingOptionalSaves.enqueue(
key,
async () => {
await this.storeCustomizationFilesCore(path, term, true);
},
options?.whenFlushRequested
);
return { kind: "completed" };
}
async _anyProcessOptionalFileEvent(path: FilePath): Promise<boolean> {
return await this.watchVaultRawEventsAsync(path);
}
@@ -1615,6 +1692,14 @@ export class ConfigSync extends LiveSyncCommands {
}
async deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite = false) {
return await this.deleteConfigOnDatabaseCore(prefixedFileName, forceWrite, false);
}
private async deleteConfigOnDatabaseCore(
prefixedFileName: FilePathWithPrefix,
forceWrite: boolean,
requireCompletion: boolean
) {
// const id = await this.path2id(prefixedFileName);
const mtime = new Date().getTime();
return await serialized("file-x-" + prefixedFileName, async () => {
@@ -1640,13 +1725,15 @@ export class ConfigSync extends LiveSyncCommands {
type: "newnote",
};
}
await this.localDatabase.putRaw(saveData);
const result = await this.localDatabase.putRaw(saveData);
if (!result.ok) throw new LiveSyncError(`Could not delete customisation: ${prefixedFileName}`);
await this.updatePluginList(false, prefixedFileName);
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Done`);
return true;
} catch (ex) {
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false;
}
});
@@ -1756,6 +1843,9 @@ export class ConfigSync extends LiveSyncCommands {
}
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
this._processOptionalFileEventWithResult.bind(this)
);
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
services.replication.processVirtualDocument.addHandler(this._anyModuleParsedReplicationResultItem.bind(this));
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));
@@ -0,0 +1,49 @@
import { compatGlobal, type CompatTimeoutHandle } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
type PendingSave = {
save: () => Promise<void>;
completion: Promise<void>;
resolve: () => void;
reject: (error: unknown) => void;
timer?: CompatTimeoutHandle;
started: boolean;
};
/** Owns the quiet-period delay and completion of coalesced customisation saves. */
export class PendingConfigSaves {
private readonly pending = new Map<string, PendingSave>();
enqueue(key: string, save: () => Promise<void>, whenFlushRequested?: Promise<void>): Promise<void> {
let group = this.pending.get(key);
if (!group) {
let resolve!: () => void;
let reject!: (error: unknown) => void;
const completion = new Promise<void>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
group = { save, completion, resolve, reject, started: false };
this.pending.set(key, group);
} else {
group.save = save;
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
}
const captured = group;
group.timer = compatGlobal.setTimeout(() => this.run(key, captured), 100);
// Capture this group: a late flush of an earlier event must not release
// a newer group which happens to use the same customisation key.
void whenFlushRequested?.then(
() => this.run(key, captured),
() => {}
);
return group.completion;
}
private run(key: string, group: PendingSave): void {
if (group.started) return;
group.started = true;
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
this.pending.delete(key);
void Promise.resolve().then(group.save).then(group.resolve, group.reject);
}
}
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PendingConfigSaves } from "./PendingConfigSaves";
function deferred() {
let resolve!: () => void;
let reject!: (error: unknown) => void;
const promise = new Promise<void>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
return { promise, resolve, reject };
}
afterEach(() => vi.useRealTimers());
describe("pending customisation saves", () => {
it("retains the quiet period and completes every coalesced notification after the actual save", async () => {
vi.useFakeTimers();
const pending = new PendingConfigSaves();
const save = deferred();
const firstSave = vi.fn(async () => {});
const lastSave = vi.fn(() => save.promise);
const first = pending.enqueue("plugin", firstSave);
await vi.advanceTimersByTimeAsync(80);
const second = pending.enqueue("plugin", lastSave);
let completed = false;
const all = Promise.all([first, second]).then(() => {
completed = true;
});
await vi.advanceTimersByTimeAsync(99);
expect(lastSave).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(firstSave).not.toHaveBeenCalled();
expect(lastSave).toHaveBeenCalledOnce();
expect(completed).toBe(false);
save.resolve();
await all;
expect(completed).toBe(true);
});
it("flushes the captured group without waiting for or releasing later notifications", async () => {
vi.useFakeTimers();
const pending = new PendingConfigSaves();
const flush = deferred();
const lateFlush = deferred();
const firstSave = deferred();
const work = vi.fn(() => firstSave.promise);
const first = pending.enqueue("plugin", work, flush.promise);
const coalesced = pending.enqueue("plugin", work, lateFlush.promise);
flush.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(work).toHaveBeenCalledOnce();
const nextWork = vi.fn(async () => {});
const later = pending.enqueue("plugin", nextWork);
lateFlush.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(nextWork).not.toHaveBeenCalled();
firstSave.resolve();
await Promise.all([first, coalesced]);
expect(nextWork).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(100);
await later;
expect(nextWork).toHaveBeenCalledOnce();
});
it("rejects every coalesced notification with the original failure and permits a later retry", async () => {
vi.useFakeTimers();
const pending = new PendingConfigSaves();
const failure = new Error("database write failed");
const save = async () => {
throw failure;
};
const first = pending.enqueue("plugin", save);
const second = pending.enqueue("plugin", save);
const outcomes = Promise.allSettled([first, second]);
await vi.advanceTimersByTimeAsync(100);
expect(await outcomes).toEqual([
{ status: "rejected", reason: failure },
{ status: "rejected", reason: failure },
]);
const retry = pending.enqueue("plugin", async () => {}, Promise.resolve());
await retry;
});
});
+14 -3
View File
@@ -20,10 +20,14 @@
MODE_SHINY,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { normalizePath } from "@/deps";
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
import {
HiddenFileSync,
HIDDEN_FILE_SYNC_DISABLED_NOTICE,
} from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
import { $msg as translateMessage } from "@/common/translation";
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
@@ -139,6 +143,11 @@
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
return await addOn.deleteData(data);
}
function checkAutomaticSyncAvailable() {
if (addOnHiddenFileSync.isThisModuleEnabled()) return true;
Logger(translateMessage(HIDDEN_FILE_SYNC_DISABLED_NOTICE), LOG_LEVEL_NOTICE);
return false;
}
function askMode(evt: MouseEvent, title: string, key: string) {
const menu = new Menu();
menu.addItem((item) => item.setTitle(title).setIsLabel(true));
@@ -149,6 +158,7 @@
item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`)
.onClick((e) => {
if (mode === MODE_AUTOMATIC) {
if (!checkAutomaticSyncAvailable()) return;
askOverwriteModeForAutomatic(evt, key);
} else {
setMode(key, mode as SYNC_MODE);
@@ -161,10 +171,11 @@
menu.showAtMouseEvent(evt);
}
function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") {
if (!checkAutomaticSyncAvailable()) return;
setMode(key, MODE_AUTOMATIC);
const configDir = normalizePath(plugin.core.services.API.getSystemConfigDir());
const files = (plugin.core.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((e) => `${configDir}/${e}`);
addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
const files = getAutomaticSyncTargetFiles(key, configDir, plugin.core.settings.pluginSyncExtendedSetting);
void addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
}
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
const menu = new Menu();
@@ -0,0 +1,13 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
export function getAutomaticSyncTargetFiles(
key: string,
configDir: string,
entries: ObsidianLiveSyncSettings["pluginSyncExtendedSetting"]
): string[] {
const keys = key.startsWith("PLUGIN_ALL/")
? [`PLUGIN_DATA/${key.substring("PLUGIN_ALL/".length)}`, `PLUGIN_MAIN/${key.substring("PLUGIN_ALL/".length)}`]
: [key];
const files = new Set(keys.flatMap((entryKey) => entries[entryKey]?.files ?? []));
return [...files].map((file) => `${configDir}/${file}`);
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { MODE_AUTOMATIC, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
function createEntries(): ObsidianLiveSyncSettings["pluginSyncExtendedSetting"] {
return {
"CONFIG/app.json": { key: "CONFIG/app.json", mode: MODE_AUTOMATIC, files: ["app.json"] },
"PLUGIN_MAIN/example": {
key: "PLUGIN_MAIN/example",
mode: MODE_AUTOMATIC,
files: ["plugins/example/main.js", "plugins/example/manifest.json"],
},
"PLUGIN_DATA/example": {
key: "PLUGIN_DATA/example",
mode: MODE_AUTOMATIC,
files: ["plugins/example/data.json"],
},
"PLUGIN_ETC/example": {
key: "PLUGIN_ETC/example",
mode: MODE_AUTOMATIC,
files: ["plugins/example/extra.json"],
},
"PLUGIN_MAIN/other": {
key: "PLUGIN_MAIN/other",
mode: MODE_AUTOMATIC,
files: ["plugins/other/main.js"],
},
};
}
describe("getAutomaticSyncTargetFiles", () => {
it("initialises a single selected item in the current configuration directory", () => {
expect(getAutomaticSyncTargetFiles("CONFIG/app.json", ".custom-config", createEntries())).toEqual([
".custom-config/app.json",
]);
});
it("initialises both saved child selections when the whole plug-in is selected", () => {
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", createEntries())).toEqual([
".obsidian/plugins/example/data.json",
".obsidian/plugins/example/main.js",
".obsidian/plugins/example/manifest.json",
]);
});
it("retains the available child files when the other child has no saved entry", () => {
const entries = createEntries();
delete entries["PLUGIN_DATA/example"];
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", entries)).toEqual([
".obsidian/plugins/example/main.js",
".obsidian/plugins/example/manifest.json",
]);
});
it("keeps an empty selection empty rather than including unrelated plug-ins", () => {
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/absent", ".obsidian", createEntries())).toEqual([]);
});
});
@@ -0,0 +1,260 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
import type { FilePath, UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { HiddenFileSync } from "./CmdHiddenFileSync";
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
const path = ".obsidian/plugins/example/main.js" as FilePath;
const content = "module.exports = class ExamplePlugin {};";
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
async function getText(entry: Parameters<typeof readContent>[0]) {
const value = await readContent(entry);
return typeof value === "string" ? value : new TextDecoder().decode(value);
}
async function createHiddenFileSync() {
const fixture = await createOptionalFileSyncDatabase();
fixtureOwners.push(fixture);
fixture.settings.usePluginSync = false;
fixture.settings.syncInternalFilesTargetPatterns = "" as typeof fixture.settings.syncInternalFilesTargetPatterns;
fixture.settings.syncInternalFilesIgnorePatterns = "" as typeof fixture.settings.syncInternalFilesIgnorePatterns;
let fileContent = content;
let stat: UXStat | null = {
ctime: 1_000,
mtime: 2_000,
size: fileContent.length,
type: "file",
};
const values = new Map<string, unknown>();
const kvDB = {
get: vi.fn(async <T>(key: string) => values.get(key) as T | undefined),
set: vi.fn(async <T>(key: string, value: T) => {
values.set(key, value);
}),
};
const storageAccess = {
statHidden: vi.fn(async () => stat),
readHiddenFileAuto: vi.fn(async () => fileContent),
writeHiddenFileAuto: vi.fn(async () => undefined),
removeHidden: vi.fn(async () => true),
isExistsIncludeHidden: vi.fn(async () => true),
ensureDir: vi.fn(async () => undefined),
triggerHiddenFile: vi.fn(async () => undefined),
setFile(nextContent: string | null, mtime = 2_000) {
fileContent = nextContent ?? "";
stat = nextContent === null ? null : { ctime: 1_000, mtime, size: fileContent.length, type: "file" };
},
};
const databaseService = { isDatabaseReady: vi.fn(() => true) };
const appLifecycle = { isReady: vi.fn(() => false), isSuspended: vi.fn(() => true) };
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings: fixture.settings,
localDatabase: fixture.localDatabase,
kvDB,
storageAccess,
services: {
...fixture.services,
database: databaseService,
appLifecycle,
path: {
...fixture.services.path,
markChangesAreSame: vi.fn(),
unmarkChanges: vi.fn(),
},
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
},
},
cacheFileRegExps: new Map(),
cacheCustomisationSyncIgnoredFiles: new Map(),
_fileInfoLastProcessed: new Map(),
_databaseInfoLastProcessed: new Map(),
_fileInfoLastKnown: new Map(),
semaphore: Semaphore(10),
_log: vi.fn(),
});
return { fixture, hiddenFileSync, storageAccess, kvDB, databaseService, appLifecycle };
}
afterEach(async () => {
vi.restoreAllMocks();
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
});
describe("Hidden File Sync optional save outcomes", () => {
it("retries a failed database write when the storage fingerprint is unchanged", async () => {
const { fixture, hiddenFileSync } = await createHiddenFileSync();
await expect(hiddenFileSync.isTargetFile(path)).resolves.toBe(true);
vi.spyOn(fixture.localDatabase, "putDBEntry").mockRejectedValueOnce(new Error("temporary database failure"));
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(false);
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(true);
expect(fixture.localDatabase.putDBEntry).toHaveBeenCalledTimes(2);
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(saved).not.toBe(false);
});
it("rejects a failed strict write and retries the same fingerprint without delaying readiness", async () => {
const { fixture, hiddenFileSync, appLifecycle } = await createHiddenFileSync();
const putDBEntry = vi
.spyOn(fixture.localDatabase, "putDBEntry")
.mockRejectedValueOnce(new Error("temporary database failure"));
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
"temporary database failure"
);
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBeUndefined();
await expect(
hiddenFileSync._processOptionalFileEventWithResult(path, {
whenFlushRequested: new Promise<void>(() => undefined),
})
).resolves.toMatchObject({
kind: "completed",
});
expect(putDBEntry).toHaveBeenCalledTimes(2);
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(saved).not.toBe(false);
if (saved) expect(await getText(saved)).toBe(content);
expect(appLifecycle.isReady()).toBe(false);
expect(appLifecycle.isSuspended()).toBe(true);
});
it("rejects a false database-write result and can store a later edit after a same-content event", async () => {
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
const putDBEntry = vi
.spyOn(fixture.localDatabase, "putDBEntry")
.mockImplementationOnce(async () => false as never);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
`Failed to store hidden file in the database: ${path}`
);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
const first = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(first).not.toBe(false);
if (first) {
const firstRevision = first._rev;
hiddenFileSync._fileInfoLastProcessed.delete(path);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
const unchanged = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(unchanged).not.toBe(false);
if (unchanged) expect(unchanged._rev).toBe(firstRevision);
}
storageAccess.setFile("module.exports = class UpdatedExamplePlugin {};", 3_000);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
const updated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(updated).not.toBe(false);
if (updated) expect(await getText(updated)).toBe("module.exports = class UpdatedExamplePlugin {};");
expect(putDBEntry).toHaveBeenCalledTimes(3);
});
it("vetoes when the physical database is unavailable and ignores disabled or filtered events", async () => {
const { fixture, hiddenFileSync, databaseService } = await createHiddenFileSync();
databaseService.isDatabaseReady.mockReturnValue(false);
await expect(
hiddenFileSync._processOptionalFileEventWithResult(path, {
whenFlushRequested: new Promise<void>(() => undefined),
})
).resolves.toMatchObject({ kind: "veto", value: expect.any(String) });
databaseService.isDatabaseReady.mockReturnValue(true);
fixture.settings.syncInternalFiles = false;
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "not-applicable",
});
fixture.settings.syncInternalFiles = true;
fixture.settings.syncInternalFilesTargetPatterns =
"^\\.obsidian/snippets/" as typeof fixture.settings.syncInternalFilesTargetPatterns;
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "not-applicable",
});
});
it("treats an absent replayed deletion as complete and leaves cache initialisation intact", async () => {
const { hiddenFileSync, storageAccess, kvDB } = await createHiddenFileSync();
storageAccess.setFile(null);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
const lifecycle = hiddenFileSync as unknown as {
_everyOnDatabaseInitialized(showNotice: boolean): Promise<boolean>;
};
hiddenFileSync.core.settings.syncInternalFiles = false;
await lifecycle._everyOnDatabaseInitialized(false);
expect(kvDB.get).toHaveBeenCalledTimes(3);
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
});
it("rejects actual deletion failures and completes duplicate deletions without another write", async () => {
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, content));
storageAccess.setFile(null);
const putRaw = vi
.spyOn(fixture.localDatabase, "putRaw")
.mockRejectedValueOnce(new Error("temporary deletion failure"));
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
"temporary deletion failure"
);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
hiddenFileSync.resetLastProcessedFile([path]);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
expect(putRaw).toHaveBeenCalledTimes(2);
});
it("recreates an empty hidden file when its database entry is logically deleted", async () => {
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, ""));
await expect(hiddenFileSync.deleteInternalFileOnDatabase(path)).resolves.toBe(true);
const deletedBase = await fixture.localDatabase.getDBEntry(
`i:${path}` as FilePath,
undefined,
false,
true,
true
);
expect(deletedBase).not.toBe(false);
if (deletedBase) expect(deletedBase.deleted).toBe(true);
if (!deletedBase) throw new Error("The logically deleted test entry could not be loaded");
vi.spyOn(fixture.localDatabase, "getDBEntry").mockResolvedValueOnce(deletedBase);
storageAccess.setFile("", 4_000);
const putDBEntry = vi.spyOn(fixture.localDatabase, "putDBEntry");
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
expect(putDBEntry).toHaveBeenCalledTimes(1);
const recreated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(recreated).not.toBe(false);
if (recreated) {
expect(recreated.deleted).not.toBe(true);
expect(await getText(recreated)).toBe("");
}
});
});
@@ -0,0 +1,127 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
import { HiddenFileSync } from "./CmdHiddenFileSync";
const hiddenPath = ".obsidian/plugins/example/main.js" as FilePath;
const prefixedPath = `i:${hiddenPath}`;
const body = "module.exports = class ExamplePlugin {};";
const allowList = "\\.(?:md|png|pdf)$";
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
async function hiddenFiles() {
const fixture = await createOptionalFileSyncDatabase();
fixtureOwners.push(fixture);
fixture.settings.usePluginSync = false;
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings: fixture.settings,
services: {
...fixture.services,
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
},
},
cacheFileRegExps: new Map(),
cacheCustomisationSyncIgnoredFiles: new Map(),
});
return { ...fixture, hiddenFileSync };
}
afterEach(async () => {
vi.restoreAllMocks();
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
});
describe("issue #1272: Hidden File Sync with normal-file selectors", () => {
it("stores a selected hidden file while the normal-file allow-list is set", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
ok: true,
});
const stored = await fixture.localDatabase.getDBEntry(prefixedPath as FilePath);
expect(stored).not.toBe(false);
if (stored) expect(await readContent(stored)).toBe(body);
});
it("reads existing hidden-file content while the normal-file allow-list is set", async () => {
const fixture = await hiddenFiles();
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
ok: true,
});
const meta = await fixture.localDatabase.getDBEntryMeta(prefixedPath as FilePath);
if (!meta) throw new Error("The seeded hidden-file Metadata is unavailable");
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
const entry = await fixture.localDatabase.getDBEntryFromMeta(meta);
expect(entry).not.toBe(false);
if (entry) expect(await readContent(entry)).toBe(body);
});
it("stores and reads the same hidden file when the normal-file allow-list is empty", async () => {
const fixture = await hiddenFiles();
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
ok: true,
});
const entry = await fixture.localDatabase.getDBEntry(prefixedPath as FilePath);
expect(entry).not.toBe(false);
if (entry) expect(await readContent(entry)).toBe(body);
});
it("continues to apply the normal-file allow-list to ordinary files", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
expect(fixture.localDatabase.isTargetFile("notes/example.md" as FilePath)).toBe(true);
expect(fixture.localDatabase.isTargetFile("notes/example.js" as FilePath)).toBe(false);
expect(fixture.localDatabase.isTargetFile("i:.obsidian/example.md" as FilePath)).toBe(true);
});
it("keeps Customisation Sync documents usable with the same normal-file allow-list", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
const path = "ix:device/PLUGIN_MAIN/example%main.js" as FilePath;
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(path, body))).resolves.toMatchObject({
ok: true,
});
const entry = await fixture.localDatabase.getDBEntry(path);
expect(entry).not.toBe(false);
if (entry) expect(await readContent(entry)).toBe(body);
});
it("keeps Hidden File Sync target and ignore patterns effective", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncInternalFilesTargetPatterns =
"^\\.obsidian/plugins/" as typeof fixture.settings.syncInternalFilesTargetPatterns;
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
await expect(fixture.hiddenFileSync.isTargetFile(".obsidian/snippets/example.css" as FilePath)).resolves.toBe(
false
);
fixture.settings.syncInternalFilesIgnorePatterns =
"main\\.js$" as typeof fixture.settings.syncInternalFilesIgnorePatterns;
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(false);
});
it("compatibility: applies the normal-file ignore-list to hidden documents", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
fixture.settings.syncIgnoreRegEx = "^\\.obsidian/" as typeof fixture.settings.syncIgnoreRegEx;
expect(fixture.localDatabase.isTargetFile(prefixedPath as FilePath)).toBe(false);
});
it("rejects hidden documents when Hidden File Sync is disabled", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncInternalFiles = false;
expect(fixture.localDatabase.isTargetFile(prefixedPath as FilePath)).toBe(false);
});
});
+158 -11
View File
@@ -54,6 +54,8 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import type { LiveSyncCore } from "@/main.ts";
import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
@@ -69,6 +71,9 @@ type HiddenFileInitialisationProgress = {
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
export const HIDDEN_FILE_SYNC_DISABLED_NOTICE =
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.";
function getComparingMTime(
doc: (MetaEntry | LoadedEntry | false) | UXFileInfo | UXStat | null | undefined,
includeDeleted = false
@@ -161,9 +166,7 @@ export class HiddenFileSync extends LiveSyncCommands {
// return true;
// }
private async _everyOnDatabaseInitialized(showNotice: boolean) {
this._fileInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed");
this._databaseInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed-database");
this._fileInfoLastKnown = await autosaveCache(this.kvDB, "hidden-file-lastKnown");
await this.ensureProcessedCaches();
if (this.isThisModuleEnabled()) {
if (this._fileInfoLastProcessed.size == 0 && this._fileInfoLastProcessed.size == 0) {
this._log(`No cache found. Performing startup scan.`, LOG_LEVEL_VERBOSE);
@@ -249,6 +252,26 @@ export class HiddenFileSync extends LiveSyncCommands {
return false;
}
async _processOptionalFileEventWithResult(
path: FilePath,
_options?: OptionalFileEventOptions
): Promise<HandlerOutcome<void, string>> {
if (!this.isThisModuleEnabled()) return { kind: "not-applicable" };
if (!(await this.isTargetFile(path))) return { kind: "not-applicable" };
if (!this._isDatabaseReady()) {
return { kind: "veto", value: "The local database is not ready" };
}
await this.ensureProcessedCaches();
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) {
return { kind: "not-applicable" };
}
if (!(await this.trackStorageFileModificationStrict(path))) {
return { kind: "not-applicable" };
}
return { kind: "completed" };
}
_anyGetOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise<boolean | "newer"> {
if (isInternalMetadata(path)) {
this.queueConflictCheck(path);
@@ -317,6 +340,51 @@ export class HiddenFileSync extends LiveSyncCommands {
_fileInfoLastKnown!: MapLike<string, number>;
_databaseInfoLastProcessed!: MapLike<string, string>;
private processedCacheStore: unknown;
private processedCacheStoreReady = false;
private processedCacheLoadStore: unknown;
private processedCacheLoad?: Promise<void>;
private async ensureProcessedCaches() {
const store = this.kvDB;
if (!store) throw new Error("The Hidden File Sync cache store is unavailable");
if (this.processedCacheStoreReady && this.processedCacheStore === store) return;
if (this.processedCacheLoad && this.processedCacheLoadStore === store) {
await this.processedCacheLoad;
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
}
return;
}
let load!: Promise<void>;
load = Promise.all([
autosaveCache<string, string>(store, "hidden-file-lastProcessed"),
autosaveCache<string, string>(store, "hidden-file-lastProcessed-database"),
autosaveCache<string, number>(store, "hidden-file-lastKnown"),
])
.then(([fileInfoLastProcessed, databaseInfoLastProcessed, fileInfoLastKnown]) => {
if (this.processedCacheLoad !== load || this.kvDB !== store) return;
this._fileInfoLastProcessed = fileInfoLastProcessed;
this._databaseInfoLastProcessed = databaseInfoLastProcessed;
this._fileInfoLastKnown = fileInfoLastKnown;
this.processedCacheStore = store;
this.processedCacheStoreReady = true;
})
.finally(() => {
if (this.processedCacheLoad === load) {
this.processedCacheLoad = undefined;
this.processedCacheLoadStore = undefined;
}
});
this.processedCacheLoadStore = store;
this.processedCacheLoad = load;
await load;
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
}
}
statToKey(stat: UXStat | null) {
return `${stat?.mtime ?? 0}-${stat?.size ?? 0}`;
}
@@ -600,7 +668,6 @@ Offline Changed files: ${processFiles.length}`;
this._log(`Hidden file:${path} is changed.`, LOG_LEVEL_VERBOSE);
stat = cache.stat;
}
this.updateLastProcessedFile(path, stat!);
const lastIsNotFound = !lastKey || lastKey.endsWith("-0-0");
const nowIsNotFound = cache.deleted;
const type = lastIsNotFound && nowIsNotFound ? "invalid" : nowIsNotFound ? "delete" : "modified";
@@ -651,6 +718,27 @@ Offline Changed files: ${processFiles.length}`;
return true;
}
private async trackStorageFileModificationStrict(path: FilePath): Promise<boolean> {
return await this.serializedForEvent(path, async () => {
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) return false;
const stat = await this.core.storageAccess.statHidden(path);
if (stat != null && stat.type != "file") return true;
const key = await this.fileToStatKey(path, stat);
if (this.getLastProcessedFileKey(path) === key) {
this._log(`${path} Already processed.`, LOG_LEVEL_DEBUG);
return true;
}
const fileInfo = await this.loadFileWithInfo(path);
if (fileInfo.deleted) {
await this.deleteInternalFileOnDatabaseStrict(path);
return true;
}
await this.storeInternalFileToDatabaseStrict(fileInfo);
return true;
});
}
// --> Conflict processing
// Keep one in-flight conflict check per path so repeated sync events do not close the active merge dialogue.
@@ -1444,6 +1532,11 @@ Offline Changed files: ${files.length}`;
initialisationProgress?: HiddenFileInitialisationProgress
) {
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
if (!this.isThisModuleEnabled()) {
this._log($msg(HIDDEN_FILE_SYNC_DISABLED_NOTICE), logLevel);
initialisationProgress?.done($msg("Hidden File Sync initialisation was not started."));
return false;
}
const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel);
// p.log("Resolving conflicts before starting...");
// await this.resolveConflictOnInternalFiles();
@@ -1494,12 +1587,25 @@ Offline Changed files: ${files.length}`;
await this.scanAllDatabaseChanges(showChildNotices, true, false);
}
p.done();
return true;
}
// <-- Initialization functions
// --> Storage To Database Functions
async __loadBaseSaveData(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
return await this.loadBaseSaveData(file, includeContent, false);
}
private async loadBaseSaveDataStrict(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
return await this.loadBaseSaveData(file, includeContent, true);
}
private async loadBaseSaveData(
file: FilePath,
includeContent: boolean,
strict: boolean
): Promise<LoadedEntry | false> {
const prefixedFileName = addPrefix(file, ICHeader);
const id = await this.path2id(prefixedFileName, ICHeader);
try {
@@ -1525,6 +1631,7 @@ Offline Changed files: ${files.length}`;
return old;
}
} catch (ex) {
if (strict) throw ex;
this._log(`Getting base save data failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
@@ -1552,6 +1659,18 @@ Offline Changed files: ${files.length}`;
}
async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
return await this.storeInternalFileToDatabaseCore(file, forceWrite, false);
}
private async storeInternalFileToDatabaseStrict(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
return await this.storeInternalFileToDatabaseCore(file, forceWrite, true);
}
private async storeInternalFileToDatabaseCore(
file: InternalFileInfo | UXFileInfo,
forceWrite: boolean,
strict: boolean
) {
const storeFilePath = stripAllPrefixes(file.path);
const storageFilePath = file.path;
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
@@ -1565,9 +1684,11 @@ Offline Changed files: ${files.length}`;
if (fileInfo.deleted) {
throw new Error(`Hidden file:${storeFilePath} is deleted. This should not be occurred.`);
}
const baseData = await this.__loadBaseSaveData(storeFilePath, true);
const baseData = strict
? await this.loadBaseSaveDataStrict(storeFilePath, true)
: await this.__loadBaseSaveData(storeFilePath, true);
if (baseData === false) throw new Error("Failed to load base data");
if (baseData._rev && !forceWrite) {
if (baseData._rev && !baseData.deleted && !baseData._deleted && !forceWrite) {
// Not newly created, we should check the content is actually modified.
const isSame = await isDocContentSame(readAsBlob(baseData), fileInfo.body);
if (isSame) {
@@ -1592,11 +1713,15 @@ Offline Changed files: ${files.length}`;
this.updateLastProcessed(storeFilePath, saveData, fileInfo.stat);
}
const success = ret && ret.ok;
if (!success && strict) {
throw new Error(`Failed to store hidden file in the database: ${storeFilePath}`);
}
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) ${success ? "Done" : "Failed"}`);
return success;
} catch (ex) {
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (strict) throw ex;
return false;
}
});
@@ -1676,6 +1801,14 @@ Offline Changed files: ${files.length}`;
}
async deleteInternalFileOnDatabase(filenameSrc: FilePath, forceWrite = false) {
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, false);
}
private async deleteInternalFileOnDatabaseStrict(filenameSrc: FilePath, forceWrite = false) {
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, true);
}
private async deleteInternalFileOnDatabaseCore(filenameSrc: FilePath, _forceWrite: boolean, strict: boolean) {
const storeFilePath = filenameSrc;
const storageFilePath = filenameSrc;
const displayFileName = filenameSrc;
@@ -1686,18 +1819,26 @@ Offline Changed files: ${files.length}`;
}
return await serialized("file-" + prefixedFileName, async () => {
try {
const baseData = await this.__loadBaseSaveData(storeFilePath, false);
const baseData = strict
? await this.loadBaseSaveDataStrict(storeFilePath, false)
: await this.__loadBaseSaveData(storeFilePath, false);
if (baseData === false) throw new Error("Failed to load base data during deleting");
if (baseData._conflicts !== undefined) {
for (const conflictRev of baseData._conflicts) {
await this.localDatabase.removeRevision(baseData._id, conflictRev);
const removed = await this.localDatabase.removeRevision(baseData._id, conflictRev);
if (strict && removed === false) {
throw new Error(`Failed to remove conflicted hidden-file revision ${conflictRev}`);
}
this._log(
`STORAGE -x> DB: ${displayFileName}: (hidden) conflict removed ${baseData._rev} => ${conflictRev}`,
LOG_LEVEL_VERBOSE
);
}
}
if (baseData.deleted) {
const alreadyDeleted = strict
? !baseData._rev || baseData.deleted || baseData._deleted
: baseData.deleted;
if (alreadyDeleted) {
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) already deleted`, LOG_LEVEL_VERBOSE);
this.updateLastProcessedDeletion(storeFilePath, baseData);
return true;
@@ -1718,11 +1859,13 @@ Offline Changed files: ${files.length}`;
return true;
} else {
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
if (strict) throw new Error(`Failed to delete hidden file from the database: ${storeFilePath}`);
return false;
}
} catch (ex) {
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (strict) throw ex;
return false;
}
});
@@ -1925,6 +2068,7 @@ Offline Changed files: ${files.length}`;
async configureHiddenFileSync(mode: OptionalSyncFeatureMode) {
let initialisationProgress: HiddenFileInitialisationProgress | undefined;
let initialised = false;
let result: ConfigureHiddenFileSyncResult;
try {
result = await configureHiddenFileSyncMode(mode, {
@@ -1954,7 +2098,7 @@ Offline Changed files: ${files.length}`;
);
},
initialise: async (direction) => {
await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
initialised = await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
initialisationProgress = undefined;
},
});
@@ -1962,7 +2106,7 @@ Offline Changed files: ${files.length}`;
initialisationProgress?.done("Failed");
throw error;
}
if (result == "ignored" || result == "disabled") {
if (result == "ignored" || result == "disabled" || !initialised) {
return;
}
// this.plugin.settings.useAdvancedMode = true;
@@ -2084,6 +2228,9 @@ Offline Changed files: ${files.length}`;
// services.databaseEvents.handleOnDatabaseInitialisation(this._everyOnInitializeDatabase.bind(this));
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
this._processOptionalFileEventWithResult.bind(this)
);
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
services.replication.processOptionalSynchroniseResult.addHandler(this._anyProcessOptionalSyncFiles.bind(this));
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));
@@ -99,6 +99,115 @@ function createHiddenRevisionOperation() {
}
describe("HiddenFileSync configuration-change notices", () => {
it.each(["pushForce", "pullForce", "safe"] as const)(
"refuses %s initialisation before processing files while Hidden File Sync is disabled",
async (direction) => {
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const path = ".obsidian/example.json" as FilePath;
const operations = {
rebuildFromStorage: vi.fn(async () => [path]),
rebuildFromDatabase: vi.fn(async () => []),
rebuildMerging: vi.fn(async () => [path]),
adoptCurrentStorageFilesAsProcessed: vi.fn(async () => undefined),
adoptCurrentDatabaseFilesAsProcessed: vi.fn(async () => undefined),
scanAllStorageChanges: vi.fn(async () => undefined),
scanAllDatabaseChanges: vi.fn(async () => undefined),
};
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
const settings = { syncInternalFiles: false };
const createProgress = vi.fn(() => progress);
const log = vi.fn();
Object.assign(hiddenFileSync, {
core: { settings },
_progress: createProgress,
_log: log,
...operations,
});
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, [path]);
for (const operation of Object.values(operations)) {
expect(operation).not.toHaveBeenCalled();
}
expect(result).toBe(false);
expect(settings.syncInternalFiles).toBe(false);
expect(createProgress).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining("Hidden File Sync is disabled"), LOG_LEVEL_NOTICE);
expect(progress.done).not.toHaveBeenCalled();
}
);
it("finishes an existing preparation Notice as not started when initialisation is disabled", async () => {
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const createProgress = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: { settings: { syncInternalFiles: false } },
_progress: createProgress,
_log: vi.fn(),
});
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true, false, progress);
expect(result).toBe(false);
expect(createProgress).not.toHaveBeenCalled();
expect(progress.done).toHaveBeenCalledExactlyOnceWith("Hidden File Sync initialisation was not started.");
});
it("does not apply a stored deletion when initialisation is requested while disabled", async () => {
const path = ".obsidian/example.json" as FilePath;
const deleted: MetaEntry = {
_id: "i:example" as DocumentID,
_rev: "2-deleted",
path: `i:${path}` as FilePathWithPrefix,
type: "plain",
ctime: 1,
mtime: 10,
size: 0,
deleted: true,
children: [],
eden: {},
};
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const deleteFile = vi.fn(async () => "OK" as const);
const updateDeletion = vi.fn();
const getFiles = vi.fn(async () => [deleted]);
const processedStorage = new Map([[path, "storage-processed"]]);
const processedDatabase = new Map([[path, "database-processed"]]);
const resetProcessed = vi.fn(() => processedDatabase.clear());
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings: { syncInternalFiles: false },
services: { vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) } },
},
_progress: vi.fn(() => progress),
_log: vi.fn(),
_verbose: vi.fn(),
_fileInfoLastProcessed: processedStorage,
_databaseInfoLastProcessed: processedDatabase,
getAllDatabaseFiles: getFiles,
getPath: (doc: MetaEntry) => doc.path,
resetLastProcessedDatabase: resetProcessed,
serializedForEvent: async (_path: unknown, operation: () => Promise<unknown>) => await operation(),
__deleteFile: deleteFile,
updateLastProcessedDeletion: updateDeletion,
adoptCurrentStorageFilesAsProcessed: vi.fn(),
adoptCurrentDatabaseFilesAsProcessed: vi.fn(),
scanAllStorageChanges: vi.fn(),
scanAllDatabaseChanges: vi.fn(),
});
await hiddenFileSync.initialiseInternalFileSync("pullForce", true);
expect(deleteFile).not.toHaveBeenCalled();
expect(updateDeletion).not.toHaveBeenCalled();
expect(getFiles).not.toHaveBeenCalled();
expect(resetProcessed).not.toHaveBeenCalled();
expect(processedStorage).toEqual(new Map([[path, "storage-processed"]]));
expect(processedDatabase).toEqual(new Map([[path, "database-processed"]]));
});
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
const commands: Array<{
id: string;
@@ -136,6 +245,13 @@ describe("HiddenFileSync configuration-change notices", () => {
expect(command?.checkCallback?.(true)).toBe(false);
}
settings.useAdvancedMode = true;
for (const commandId of commandIds) {
const command = commands.find(({ id }) => id === commandId);
expect(command?.checkCallback?.(true)).toBe(false);
expect(command?.checkCallback?.(false)).toBe(false);
}
settings.syncInternalFiles = true;
settings.useAdvancedMode = true;
for (const commandId of commandIds) {
@@ -252,6 +368,7 @@ describe("HiddenFileSync configuration-change notices", () => {
const scanAllDatabaseChanges = vi.fn(async () => undefined);
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: { settings: { syncInternalFiles: true } },
_progress: vi.fn(() => progress),
rebuildMerging,
adoptCurrentStorageFilesAsProcessed,
@@ -260,14 +377,83 @@ describe("HiddenFileSync configuration-change notices", () => {
scanAllDatabaseChanges,
});
await hiddenFileSync.initialiseInternalFileSync("safe", true);
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true);
expect(result).toBe(true);
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
expect(progress.done).toHaveBeenCalledOnce();
});
it.each(["pushForce", "pullForce", "safe"] as const)(
"retains the selected files and completes %s initialisation when enabled",
async (direction) => {
const files = [".obsidian/plugins/example/main.js", ".obsidian/plugins/example/data.json"] as FilePath[];
const entries = files.map((path) => ({ path: `i:${path}` as FilePathWithPrefix }));
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const rebuildFromStorage = vi.fn(async () => files);
const rebuildFromDatabase = vi.fn(async () => entries);
const rebuildMerging = vi.fn(async () => files);
const adoptStorage = vi.fn();
const adoptDatabase = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: { settings: { syncInternalFiles: true } },
_progress: vi.fn(() => progress),
getPath: (entry: { path: FilePathWithPrefix }) => entry.path,
rebuildFromStorage,
rebuildFromDatabase,
rebuildMerging,
adoptCurrentStorageFilesAsProcessed: adoptStorage,
adoptCurrentDatabaseFilesAsProcessed: adoptDatabase,
scanAllStorageChanges: vi.fn(),
scanAllDatabaseChanges: vi.fn(),
});
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, files);
expect(result).toBe(true);
if (direction === "pushForce") {
expect(rebuildFromStorage).toHaveBeenCalledExactlyOnceWith(false, files, false);
expect(rebuildFromDatabase).not.toHaveBeenCalled();
expect(rebuildMerging).not.toHaveBeenCalled();
} else if (direction === "pullForce") {
expect(rebuildFromDatabase).toHaveBeenCalledExactlyOnceWith(false, files, false);
expect(rebuildFromStorage).not.toHaveBeenCalled();
expect(rebuildMerging).not.toHaveBeenCalled();
} else {
expect(rebuildMerging).toHaveBeenCalledExactlyOnceWith(false, files);
expect(rebuildFromStorage).not.toHaveBeenCalled();
expect(rebuildFromDatabase).not.toHaveBeenCalled();
}
expect(adoptStorage).toHaveBeenCalledExactlyOnceWith(files);
expect(adoptDatabase).toHaveBeenCalledExactlyOnceWith(files);
expect(progress.done).toHaveBeenCalledOnce();
}
);
it("does not report completion when the initialisation entry point refuses to start", async () => {
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
await handlers.enable();
await handlers.initialise("safe");
return "enabled";
});
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const log = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: { services: { setting: { applyPartial: vi.fn() } } },
_progress: vi.fn(() => progress),
_log: log,
initialiseInternalFileSync: vi.fn(async () => false),
});
await hiddenFileSync.configureHiddenFileSync("MERGE");
expect(log).not.toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number));
});
it("retirement guard: does not restore separate gathering and restart Notices", async () => {
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
await handlers.enable();
@@ -286,7 +472,7 @@ describe("HiddenFileSync configuration-change notices", () => {
const applyPartial = vi.fn(async () => {
events.push("apply-settings");
});
const initialiseInternalFileSync = vi.fn(async () => undefined);
const initialiseInternalFileSync = vi.fn(async () => true);
const log = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
@@ -8,14 +8,13 @@ import {
type DocumentID,
type EntryDoc,
type EntryLeaf,
type FilePathWithPrefix,
type MetaEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { getNoFromRev } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import { LiveSyncCommands } from "@/features/LiveSyncCommands";
import { serialized } from "octagonal-wheels/concurrency/lock_v2";
import { arrayToChunkedArray } from "octagonal-wheels/collection";
import { EVENT_ANALYSE_DB_USAGE, EVENT_REQUEST_PERFORM_GC_V3, eventHub } from "@/common/events";
import { EVENT_REQUEST_PERFORM_GC_V3, eventHub } from "@/common/events";
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import type { ReplicatorInstance } from "@vrtmrz/livesync-commonlib/replication";
import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils";
@@ -61,18 +60,6 @@ export class LocalDatabaseMaintenance extends LiveSyncCommands {
}
onload(): void | Promise<void> {
// NO OP.
this.services.API.addCommand({
id: "analyse-database",
name: "Analyse Database Usage (advanced)",
icon: "database-search",
checkCallback: (checking) => {
if (!this.settings.useAdvancedMode || !this._isDatabaseReady()) return false;
if (!checking) {
void this.analyseDatabase();
}
return true;
},
});
this.services.API.addCommand({
id: "gc-v3",
name: "Garbage Collection V3 (advanced, beta)",
@@ -88,7 +75,6 @@ export class LocalDatabaseMaintenance extends LiveSyncCommands {
return true;
},
});
eventHub.onEvent(EVENT_ANALYSE_DB_USAGE, () => this.analyseDatabase());
eventHub.onEvent(EVENT_REQUEST_PERFORM_GC_V3, () => this.gcv3());
}
async allChunks(includeDeleted: boolean = false) {
@@ -550,218 +536,6 @@ Success: ${successCount}, Errored: ${errored}`;
await kvDB.set(DB_KEY_CHUNK_SET, chunkSet);
}
// Analyse the database and report chunk usage.
async analyseDatabase() {
if (!(await this.ensureAvailable("Analyse Database Usage"))) return;
const db = this.localDatabase.localDatabase;
// Map of chunk ID to its info
type ChunkInfo = {
id: DocumentID;
refCount: number;
length: number;
};
const chunkMap = new Map<DocumentID, Set<ChunkInfo>>();
// Map of document ID to its info
type DocumentInfo = {
id: DocumentID;
rev: Rev;
chunks: Set<ChunkID>;
uniqueChunks: Set<ChunkID>;
sharedChunks: Set<ChunkID>;
path: FilePathWithPrefix;
};
const docMap = new Map<DocumentID, Set<DocumentInfo>>();
const info = await db.info();
// Total number of revisions to process (approximate)
const maxSeq = Number.parseInt(`${info.update_seq ?? 0}`, 10);
let processed = 0;
let read = 0;
let errored = 0;
// Fetch Tasks
const ft = [] as ReturnType<typeof fetchRevision>[];
// Fetch a specific revision of a document and make note of its chunks, or add chunk info.
const fetchRevision = async (id: DocumentID, rev: Rev, seq: string | number) => {
try {
processed++;
const doc = await db.get(id, { rev: rev });
if (doc) {
if ("children" in doc) {
const id = doc._id;
const rev = doc._rev;
const children = (doc.children || []) as DocumentID[];
const set = docMap.get(id) || new Set();
set.add({
id,
rev,
chunks: new Set(children),
uniqueChunks: new Set(),
sharedChunks: new Set(),
path: doc.path,
});
docMap.set(id, set);
} else if (doc.type === EntryTypes.CHUNK) {
const id = doc._id;
if (chunkMap.has(id)) {
return;
}
if (doc._deleted) {
// Deleted chunk, skip (possibly resurrected later)
return;
}
const length = doc.data.length;
const set = chunkMap.get(id) || new Set();
set.add({ id, length, refCount: 0 });
chunkMap.set(id, set);
}
read++;
} else {
this._log(`Analysing Database: not found: ${id} / ${rev}`);
errored++;
}
} catch (error) {
this._log(`Error fetching document ${id} / ${rev}: $`, LOG_LEVEL_NOTICE);
this._log(error, LOG_LEVEL_VERBOSE);
errored++;
}
if (processed % 100 == 0) {
this._log(`Analysing database: ${read} (${errored}) / ${maxSeq} `, LOG_LEVEL_NOTICE, "db-analyse");
}
};
// Enumerate all documents and their revisions.
const IDs = this.localDatabase.findEntryNames("", "", {});
for await (const id of IDs) {
const revList = await this.localDatabase.getRaw(id as DocumentID, {
revs: true,
revs_info: true,
conflicts: true,
});
const revInfos = revList._revs_info || [];
for (const revInfo of revInfos) {
// All available revisions should be processed.
// If the revision is not available, it means the revision is already tombstoned.
if (revInfo.status == "available") {
// Schedule fetch task
ft.push(fetchRevision(id as DocumentID, revInfo.rev, 0));
}
}
}
// Wait for all fetch tasks to complete.
await Promise.all(ft);
// Reference count marking and unique/shared chunk classification.
for (const [, docRevs] of docMap) {
for (const docRev of docRevs) {
for (const chunkId of docRev.chunks) {
const chunkInfos = chunkMap.get(chunkId);
if (chunkInfos) {
for (const chunkInfo of chunkInfos) {
if (chunkInfo.refCount === 0) {
docRev.uniqueChunks.add(chunkId);
} else {
docRev.sharedChunks.add(chunkId);
}
chunkInfo.refCount++;
}
}
}
}
}
// Prepare results
const result = [];
// Calculate total size of chunks in the given set.
const getTotalSize = (ids: Set<DocumentID>) => {
return [...ids].reduce((acc, chunkId) => {
const chunkInfos = chunkMap.get(chunkId);
if (chunkInfos) {
for (const chunkInfo of chunkInfos) {
acc += chunkInfo.length;
}
}
return acc;
}, 0);
};
// Compile results for each document revision
for (const doc of docMap.values()) {
for (const rev of doc) {
const title = `${rev.path} (${rev.rev})`;
const id = rev.id;
const revStr = `${getNoFromRev(rev.rev)}`;
const revHash = rev.rev.split("-")[1].substring(0, 6);
const path = rev.path;
const uniqueChunkCount = rev.uniqueChunks.size;
const sharedChunkCount = rev.sharedChunks.size;
const uniqueChunkSize = getTotalSize(rev.uniqueChunks);
const sharedChunkSize = getTotalSize(rev.sharedChunks);
result.push({
title,
path,
rev: revStr,
revHash,
id,
uniqueChunkCount: uniqueChunkCount,
sharedChunkCount,
uniqueChunkSize: uniqueChunkSize,
sharedChunkSize: sharedChunkSize,
});
}
}
const titleMap = {
title: "Title",
id: "Document ID",
path: "Path",
rev: "Revision No",
revHash: "Revision Hash",
uniqueChunkCount: "Unique Chunk Count",
sharedChunkCount: "Shared Chunk Count",
uniqueChunkSize: "Unique Chunk Size",
sharedChunkSize: "Shared Chunk Size",
} as const;
// Enumerate orphan chunks (not referenced by any document)
const orphanChunks = [...chunkMap.entries()].filter(([chunkId, infos]) => {
const totalRefCount = [...infos].reduce((acc, info) => acc + info.refCount, 0);
return totalRefCount === 0;
});
const orphanChunkSize = orphanChunks.reduce((acc, [chunkId, infos]) => {
for (const info of infos) {
acc += info.length;
}
return acc;
}, 0);
result.push({
title: "__orphan",
id: "__orphan",
path: "__orphan",
rev: "1",
revHash: "xxxxx",
uniqueChunkCount: orphanChunks.length,
sharedChunkCount: 0,
uniqueChunkSize: orphanChunkSize,
sharedChunkSize: 0,
} as const);
const csvSrc = result.map((e) => {
return [
`${e.title.replace(/"/g, '""')}"`,
`${e.id}`,
`${e.path}`,
`${e.rev}`,
`${e.revHash}`,
`${e.uniqueChunkCount}`,
`${e.sharedChunkCount}`,
`${e.uniqueChunkSize}`,
`${e.sharedChunkSize}`,
].join("\t");
});
// Add title row
csvSrc.unshift(Object.values(titleMap).join("\t"));
const csv = csvSrc.join("\n");
// Prompt to copy to clipboard
await this.services.UI.promptCopyToClipboard("Database Analysis data (TSV):", csv);
}
async compactDatabase() {
const replicator = this.core.replicator;
if (!canCompactCouchDBRemote(replicator)) return;
@@ -42,6 +42,7 @@ import {
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LocalDatabaseMaintenance } from "./CmdLocalDatabaseMainte";
import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites";
import { eventHub, EVENT_REQUEST_PERFORM_GC_V3 } from "@/common/events";
function createPrerequisites(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
const askSelectStringDialogue = vi.fn(
@@ -63,7 +64,7 @@ function createPrerequisites(settingsOverride: Partial<typeof DEFAULT_SETTINGS>
}
describe("LocalDatabaseMaintenance prerequisites", () => {
it("shows database analysis in Advanced mode and Garbage Collection only in applicable Edge Case mode", () => {
it("shows Garbage Collection only in applicable Edge Case mode", () => {
const commands: Array<{
id: string;
checkCallback?: (checking: boolean) => boolean | void;
@@ -90,15 +91,15 @@ describe("LocalDatabaseMaintenance prerequisites", () => {
_isDatabaseReady: vi.fn(() => true),
});
vi.mocked(eventHub.onEvent).mockClear();
maintenance.onload();
expect(eventHub.onEvent).toHaveBeenCalledTimes(1);
expect(eventHub.onEvent).toHaveBeenCalledWith(EVENT_REQUEST_PERFORM_GC_V3, expect.any(Function));
const analyse = commands.find(({ id }) => id === "analyse-database");
const garbageCollect = commands.find(({ id }) => id === "gc-v3");
expect(analyse?.checkCallback?.(true)).toBe(false);
expect(garbageCollect?.checkCallback?.(true)).toBe(false);
settings.useAdvancedMode = true;
expect(analyse?.checkCallback?.(true)).toBe(true);
expect(garbageCollect?.checkCallback?.(true)).toBe(false);
settings.useEdgeCaseMode = true;
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { ValueStoreError } from "@vrtmrz/value-store";
import { AutoAccepting, DEFAULT_SETTINGS, type P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
AcceptedStatus,
@@ -33,6 +34,14 @@
let settings = $state<P2PSyncSetting>(initialSettings);
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 eRelay = $state<string>(initialSettings.P2P_relays);
@@ -68,6 +77,7 @@
);
async function saveAndApply() {
const savedDeviceName = eDeviceName;
// const newSettings = {
// ...currentSettings(),
// P2P_Enabled: eP2PEnabled,
@@ -92,8 +102,39 @@
},
true
);
services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, eDeviceName);
deviceName = eDeviceName;
try {
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() {
eP2PEnabled = settings.P2P_Enabled;
@@ -110,10 +151,7 @@
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
const applyLoadSettings = (d: P2PSyncSetting, force: boolean) => {
if (force) {
const initDeviceName =
services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? services.vault.getVaultName();
deviceName = initDeviceName;
eDeviceName = initDeviceName;
void loadDeviceName(true).catch(() => undefined);
}
const { P2P_relays, P2P_roomID, P2P_passphrase, P2P_AppID, P2P_AutoAccepting } = d;
if (force || !isP2PEnabledModified) eP2PEnabled = d.P2P_Enabled;
@@ -129,6 +167,7 @@
settings = d;
};
onMount(() => {
mounted = true;
const r = events.onEvent("setting-saved", async (d) => {
applyLoadSettings(d, false);
closeServer();
@@ -143,15 +182,52 @@
const r3 = events.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
replicatorInfo = status;
});
applyLoadSettings(currentSettings(), true);
applyLoadSettings(currentSettings(), false);
void loadInitialPreferences().catch(() => undefined);
host.p2p.diagnostics.requestStatus();
return () => {
mounted = false;
r();
rx();
r2();
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(() => {
return serverInfo?.isConnected ?? false;
});
@@ -234,26 +310,42 @@
}
const initialDialogStatusKey = `p2p-dialog-status`;
const getDialogStatus = () => {
const readDialogStatus = async (): Promise<{ notice?: boolean; setting?: boolean }> => {
try {
const initialDialogStatus = JSON.parse(services.config.getSmallConfig(initialDialogStatusKey) ?? "{}") as {
notice?: boolean;
setting?: boolean;
const initialDialogStatus = await services.config.getObject(initialDialogStatusKey);
if (typeof initialDialogStatus !== "object" || initialDialogStatus === null || Array.isArray(initialDialogStatus)) {
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 {
return {};
} catch (error) {
if (error instanceof ValueStoreError && error.code === "invalid-data") {
// Invalid optional UI state is recovered with the historical defaults.
return {};
}
throw error;
}
};
const initialDialogStatus = getDialogStatus();
let isNoticeOpened = $state<boolean>(initialDialogStatus.notice ?? true);
let isSettingOpened = $state<boolean>(initialDialogStatus.setting ?? true);
let isNoticeOpened = $state<boolean>(true);
let isSettingOpened = $state<boolean>(true);
$effect(() => {
if (!mounted || !dialogStatusReady) return;
const dialogStatus = {
notice: isNoticeOpened,
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(() => {
return services.API.getPlatform() === "obsidian";
@@ -261,6 +353,9 @@
</script>
<article>
{#each Object.values(storageErrors) as storageError}
<p role="alert">{storageError}</p>
{/each}
<h1>{_msg("Peer to Peer Replicator")}</h1>
<details bind:open={isNoticeOpened}>
<summary>{_msg("P2P.Note.Summary")}</summary>
@@ -345,6 +440,7 @@
type="text"
placeholder="iphone-16"
bind:value={eDeviceName}
disabled={!deviceNameReady}
autocomplete="off"
/>
</label>
@@ -383,7 +479,7 @@
</tr> -->
</tbody>
</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
>
<button disabled={!isAnyModified} class="button" onclick={revert}>{_msg("Revert changes")}</button>
@@ -1,4 +1,5 @@
import { ItemView, Notice, Setting, type WorkspaceLeaf } from "@/deps";
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import {
REVIEW_HARNESS_SCENARIOS,
type ReviewHarnessScenarioId,
@@ -101,7 +102,7 @@ export class ReviewHarnessView extends ItemView {
true
);
this.addActionButton(actions, "Restart and return", "review-harness-restart", () =>
this.controller.prepareCompatibilityReviewRestart()
this.controller.prepareCompatibilityReviewRestart().catch((error) => Logger(error, LOG_LEVEL_NOTICE))
);
}
}
@@ -37,6 +37,14 @@ export const REVIEW_HARNESS_SCENARIOS = [
mode: "automatic",
access: "read-only",
},
{
id: "file-content-hash-performance",
title: "File content hash performance",
description:
"Compares SHA-256 and xxHash64 on fixed in-memory Blob inputs of 4 KiB, 64 KiB, 1 MiB, and 50 MiB. Input construction is excluded from hash timings; engine initialisation and warm-up are reported separately. Keep Obsidian in the foreground.",
mode: "manual",
access: "read-only",
},
] as const;
export const REVIEW_HARNESS_SCENARIO_IDS = REVIEW_HARNESS_SCENARIOS.map(({ id }) => id);
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { NEW_VAULT_SETTINGS, type SettingsMigrationState } from "@vrtmrz/livesync-commonlib/settings";
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
import {
REVIEW_HARNESS_SCENARIOS,
REVIEW_HARNESS_SCENARIO_IDS,
formatReviewHarnessReport,
inspectCompatibilityReview,
@@ -76,7 +77,12 @@ describe("Review Harness contract", () => {
"compatibility-review",
"vault-round-trip",
"id-generation-performance",
"file-content-hash-performance",
]);
expect(REVIEW_HARNESS_SCENARIOS.find(({ id }) => id === "file-content-hash-performance")).toMatchObject({
mode: "manual",
access: "read-only",
});
});
it("checks that an existing Vault exposes the seven synchronisation choices as booleans", () => {
@@ -22,9 +22,10 @@ export interface ReviewHarnessRuntime {
openCompatibilityReview(): Promise<void>;
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
runIdBenchmark(): Promise<ReviewHarnessScenarioResult>;
readContinuation(): string | null;
writeContinuation(value: string): void;
deleteContinuation(): void;
runFileContentHashBenchmark(): Promise<ReviewHarnessScenarioResult>;
readContinuation(): Promise<string | null>;
writeContinuation(value: string): Promise<void>;
deleteContinuation(): Promise<void>;
restart(): void;
reportError(error: unknown): void;
copyText(value: string): Promise<void>;
@@ -67,6 +68,7 @@ export class ReviewHarnessController {
private current: ReviewHarnessScenarioId | null = null;
private resumedRequestId: string | null = null;
private continuationError: string | null = null;
private continuationConsumption: Promise<void> | undefined;
constructor(private readonly runtime: ReviewHarnessRuntime) {}
@@ -101,10 +103,20 @@ export class ReviewHarnessController {
};
}
consumeContinuation(): void {
const serialised = this.runtime.readContinuation();
consumeContinuation(): Promise<void> {
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;
this.runtime.deleteContinuation();
await this.runtime.deleteContinuation();
const parsed = parsePendingReviewRun(serialised);
if (!parsed.pendingRun) {
this.continuationError = parsed.error ?? "The Review Harness continuation was invalid.";
@@ -162,6 +174,8 @@ export class ReviewHarnessController {
result = await this.runtime.runVaultRoundTrip();
} else if (id === "id-generation-performance") {
result = await this.runtime.runIdBenchmark();
} else if (id === "file-content-hash-performance") {
result = await this.runtime.runFileContentHashBenchmark();
} else {
const inspection = this.inspectCompatibilityReview();
result =
@@ -229,7 +243,7 @@ export class ReviewHarnessController {
this.record("scenario-failed", id);
}
prepareCompatibilityReviewRestart(): void {
async prepareCompatibilityReviewRestart(): Promise<void> {
const requestedAt = this.runtime.now().toISOString();
const pending = {
formatVersion: 1,
@@ -238,7 +252,7 @@ export class ReviewHarnessController {
stage: "awaiting-restart",
requestedAt,
} as const;
this.runtime.writeContinuation(JSON.stringify(pending));
await this.runtime.writeContinuation(JSON.stringify(pending));
this.results["compatibility-review"] = {
status: "waiting-for-user",
detail: "Restart requested. The one-shot continuation will be removed before the review resumes.",
@@ -85,14 +85,19 @@ function createRuntime(): ReviewHarnessRuntime & {
detail: "ID generation measurements completed.",
observations: ["Chunk 256 B: 1000 IDs total=43.00 ms; per ID=0.0430 ms"],
})),
readContinuation() {
runFileContentHashBenchmark: vi.fn(async () => ({
status: "passed" as const,
detail: "File content hash measurements completed.",
observations: ["SHA-256, 4 KiB: median=1.00 ms; range=1.00–1.00 ms"],
})),
async readContinuation() {
return this.continuation;
},
writeContinuation(value) {
async writeContinuation(value) {
this.events.push("write");
this.continuation = value;
},
deleteContinuation() {
async deleteContinuation() {
this.events.push("delete");
this.continuation = null;
},
@@ -121,6 +126,7 @@ describe("ReviewHarnessController", () => {
expect(controller.snapshot().results["settings-lifecycle"].status).toBe("passed");
expect(controller.snapshot().results["vault-round-trip"].status).toBe("idle");
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
expect(controller.snapshot().results["file-content-hash-performance"].status).toBe("idle");
});
it("runs the Vault fixture scenario only when it is selected explicitly", async () => {
@@ -185,6 +191,42 @@ describe("ReviewHarnessController", () => {
expect(runtime.reportError).toHaveBeenCalledOnce();
});
it("runs file content hash measurements on request without running them automatically", async () => {
const runtime = createRuntime();
const controller = new ReviewHarnessController(runtime);
await controller.runAutomaticScenarios();
expect(runtime.runFileContentHashBenchmark).not.toHaveBeenCalled();
await controller.runScenario("file-content-hash-performance");
expect(runtime.runFileContentHashBenchmark).toHaveBeenCalledOnce();
expect(controller.snapshot().results["file-content-hash-performance"]).toMatchObject({ status: "passed" });
expect(runtime.runVaultRoundTrip).not.toHaveBeenCalled();
});
it("includes file content hash measurements in Full review", async () => {
const runtime = createRuntime();
const controller = new ReviewHarnessController(runtime);
await controller.runAllScenarios();
expect(runtime.runFileContentHashBenchmark).toHaveBeenCalledOnce();
expect(controller.snapshot().results["file-content-hash-performance"].status).toBe("passed");
});
it("excludes an unexpected file content hash error from the copied report", async () => {
const runtime = createRuntime();
runtime.runFileContentHashBenchmark = vi.fn().mockRejectedValue(new Error("private benchmark content"));
const controller = new ReviewHarnessController(runtime);
await controller.runScenario("file-content-hash-performance");
expect(controller.snapshot().results["file-content-hash-performance"].status).toBe("failed");
expect(controller.createReport()).not.toContain("private benchmark content");
expect(runtime.reportError).toHaveBeenCalledOnce();
});
it("does not overlap an ID measurement with another scenario", async () => {
const runtime = createRuntime();
let finish!: () => void;
@@ -209,7 +251,7 @@ describe("ReviewHarnessController", () => {
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();
runtime.continuation = JSON.stringify({
formatVersion: 1,
@@ -220,7 +262,7 @@ describe("ReviewHarnessController", () => {
});
const controller = new ReviewHarnessController(runtime);
controller.consumeContinuation();
await controller.consumeContinuation();
expect(runtime.events).toEqual(["delete"]);
expect(controller.snapshot().results["compatibility-review"]).toMatchObject({
@@ -229,7 +271,7 @@ describe("ReviewHarnessController", () => {
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();
runtime.continuation = JSON.stringify({
formatVersion: 1,
@@ -240,7 +282,7 @@ describe("ReviewHarnessController", () => {
});
const controller = new ReviewHarnessController(runtime);
controller.consumeContinuation();
await controller.consumeContinuation();
expect(controller.snapshot().continuationError).toContain("private-vault");
expect(controller.snapshot().results["compatibility-review"].detail).toBe(
@@ -250,7 +292,7 @@ describe("ReviewHarnessController", () => {
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();
runtime.continuation = JSON.stringify({
formatVersion: 1,
@@ -261,18 +303,18 @@ describe("ReviewHarnessController", () => {
});
const controller = new ReviewHarnessController(runtime);
controller.consumeContinuation();
await controller.consumeContinuation();
expect(controller.snapshot().results["compatibility-review"].status).toBe("failed");
expect(controller.createReport()).not.toContain("private-vault");
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 controller = new ReviewHarnessController(runtime);
controller.prepareCompatibilityReviewRestart();
await controller.prepareCompatibilityReviewRestart();
expect(runtime.events).toEqual(["write", "restart"]);
expect(JSON.parse(runtime.continuation!)).toMatchObject({
@@ -282,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 () => {
const runtime = createRuntime();
const controller = new ReviewHarnessController(runtime);
@@ -0,0 +1,149 @@
import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes";
export const REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS = [
{ label: "4 KiB", bytes: 4 * 1024 },
{ label: "64 KiB", bytes: 64 * 1024 },
{ label: "1 MiB", bytes: 1024 * 1024 },
{ label: "50 MiB", bytes: 50 * 1024 * 1024 },
] as const;
const CONTENT_BLOCK_BYTES = 64 * 1024;
const SAMPLE_COUNT = 3;
export interface FileContentHashBenchmarkOperations {
readonly xxHashInitialisationMs: number;
createInput(bytes: number): Blob;
sha256(content: Blob): Promise<`sha256:${string}`>;
xxHash64(content: Blob): Promise<bigint>;
}
type BenchmarkPerformance = Pick<Performance, "now"> & {
readonly memory?: { readonly usedJSHeapSize: number };
};
type HashAlgorithm = "SHA-256" | "xxHash64";
function readHeap(clock: BenchmarkPerformance): number | undefined {
try {
const bytes = clock.memory?.usedJSHeapSize;
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
} catch {
return undefined;
}
}
function summary(samples: readonly number[]): string {
const sorted = [...samples].sort((a, b) => a - b);
const middle = sorted[Math.floor(sorted.length / 2)];
return `median=${middle.toFixed(2)} ms; range=${sorted[0].toFixed(2)}–${sorted[sorted.length - 1].toFixed(2)} ms`;
}
function hashKey(hash: `sha256:${string}` | bigint): string {
if (typeof hash === "bigint") return hash.toString(16);
return hash;
}
export function createFixedFileContentHashInput(bytes: number): Blob {
const block = new Uint8Array(CONTENT_BLOCK_BYTES);
for (let index = 0; index < block.length; index++) block[index] = (index * 31 + 17) & 0xff;
const parts: BlobPart[] = [];
for (let remaining = bytes; remaining > 0; remaining -= CONTENT_BLOCK_BYTES) {
parts.push(block.subarray(0, Math.min(remaining, CONTENT_BLOCK_BYTES)));
}
return new Blob(parts);
}
export async function runReviewHarnessFileContentHashBenchmark(
operations: FileContentHashBenchmarkOperations,
clock: BenchmarkPerformance = performance,
yieldControl: () => Promise<void> = () => new Promise((resolve) => window.setTimeout(resolve, 0))
): Promise<ReviewHarnessScenarioResult> {
if (!Number.isFinite(operations.xxHashInitialisationMs) || operations.xxHashInitialisationMs < 0) {
throw new Error("The xxHash64 initialisation time is invalid.");
}
const before = readHeap(clock);
let highest = before;
const sampleHeap = () => {
const value = readHeap(clock);
if (value !== undefined) highest = Math.max(highest ?? value, value);
return value;
};
const inputs: { readonly label: string; readonly bytes: number; readonly content: Blob }[] = [];
for (const { label, bytes } of REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS) {
await yieldControl();
inputs.push({ label, bytes, content: operations.createInput(bytes) });
sampleHeap();
}
const hash = (algorithm: HashAlgorithm, content: Blob) =>
algorithm === "SHA-256" ? operations.sha256(content) : operations.xxHash64(content);
const warmupTimes = new Map<HashAlgorithm, number>();
const signatures = new Map<string, string>();
const checkHash = (algorithm: HashAlgorithm, label: string, result: `sha256:${string}` | bigint): void => {
const value = hashKey(result);
const key = `${algorithm}:${label}`;
const previous = signatures.get(key);
if (previous !== undefined && previous !== value) {
throw new Error(`The ${algorithm} result changed for fixed in-memory content.`);
}
signatures.set(key, value);
};
for (const algorithm of ["SHA-256", "xxHash64"] as const) {
await yieldControl();
const started = clock.now();
const result = await hash(algorithm, inputs[0].content);
const elapsed = clock.now() - started;
checkHash(algorithm, inputs[0].label, result);
warmupTimes.set(algorithm, elapsed);
sampleHeap();
}
const observations = [
"Fixed in-memory Blob inputs contain the same deterministic byte sequence at 4 KiB, 64 KiB, 1 MiB, and 50 MiB. Input construction is excluded from timings.",
`xxHash64 engine initialisation: ${operations.xxHashInitialisationMs.toFixed(2)} ms; measured separately.`,
`Warm-up on 4 KiB, one call per algorithm: SHA-256=${warmupTimes.get("SHA-256")!.toFixed(2)} ms; xxHash64=${warmupTimes.get("xxHash64")!.toFixed(2)} ms; excluded from samples.`,
"Each algorithm has three samples per input. SHA-256 uses the Self-hosted LiveSync file content hash helper; xxHash64 timings include Blob.arrayBuffer() and h64Raw. Calls run serially, with alternating algorithm order and a UI yield between calls.",
];
for (const input of inputs) {
const samples: Record<HashAlgorithm, number[]> = { "SHA-256": [], xxHash64: [] };
for (let sample = 0; sample < SAMPLE_COUNT; sample++) {
const order: readonly HashAlgorithm[] =
sample % 2 === 0 ? ["SHA-256", "xxHash64"] : ["xxHash64", "SHA-256"];
for (const algorithm of order) {
await yieldControl();
const started = clock.now();
const result = await hash(algorithm, input.content);
const elapsed = clock.now() - started;
checkHash(algorithm, input.label, result);
if (!Number.isFinite(elapsed) || elapsed < 0) {
throw new Error(`The ${algorithm} measurement returned an invalid duration.`);
}
samples[algorithm].push(elapsed);
sampleHeap();
}
}
for (const algorithm of ["SHA-256", "xxHash64"] as const) {
observations.push(`${algorithm}, ${input.label}: ${summary(samples[algorithm])}.`);
}
}
const after = sampleHeap();
if (highest === undefined) {
observations.push("JavaScript heap: unavailable on this device.");
} else {
const mib = (bytes: number | undefined) =>
bytes === undefined ? "unavailable" : `${(bytes / 1048576).toFixed(2)} MiB`;
observations.push(
`JavaScript heap: before=${mib(before)}; highest sampled=${mib(highest)}; after=${mib(after)}.`
);
}
observations.push(
"Heap samples are approximate, may include other Obsidian work, and are affected by garbage collection. They are neither total app RAM nor a true peak."
);
return { status: "passed", detail: "File content hash measurements completed.", observations };
}
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import {
REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS,
createFixedFileContentHashInput,
runReviewHarnessFileContentHashBenchmark,
type FileContentHashBenchmarkOperations,
} from "./reviewHarnessFileContentHashBenchmark";
function fixture() {
let elapsed = 0;
let activeCalls = 0;
let maximumConcurrentCalls = 0;
let yieldCount = 0;
const contentSizes = new WeakMap<Blob, number>();
const contentBySize = new Map<number, Blob>();
const callCounts = new Map<string, number>();
const observedInputs = new Map<string, Blob[]>();
const operations: FileContentHashBenchmarkOperations = {
xxHashInitialisationMs: 12.5,
createInput: (bytes) => {
elapsed += 10000;
const content = new Blob([new Uint8Array(4)]);
contentSizes.set(content, bytes);
contentBySize.set(bytes, content);
return content;
},
sha256: async (content) => {
await measure("SHA-256", content);
return `sha256:${contentSizes.get(content)!}`;
},
xxHash64: async (content) => {
await measure("xxHash64", content);
return BigInt(contentSizes.get(content)!);
},
};
async function measure(algorithm: string, content: Blob): Promise<void> {
activeCalls++;
maximumConcurrentCalls = Math.max(maximumConcurrentCalls, activeCalls);
const bytes = contentSizes.get(content)!;
const key = `${algorithm}:${bytes}`;
const count = callCounts.get(key) ?? 0;
callCounts.set(key, count + 1);
observedInputs.set(key, [...(observedInputs.get(key) ?? []), content]);
const durations =
bytes === 4 * 1024
? algorithm === "SHA-256"
? [100, 1, 3, 5]
: [200, 11, 13, 15]
: algorithm === "SHA-256"
? [1, 3, 5]
: [11, 13, 15];
await Promise.resolve();
elapsed += durations[count];
activeCalls--;
}
return {
operations,
now: () => elapsed,
yieldControl: async () => {
yieldCount++;
await Promise.resolve();
},
inputs: () => ({ contentBySize, callCounts, observedInputs, yieldCount, maximumConcurrentCalls }),
};
}
describe("Review Harness file content hash measurements", () => {
it("measures fixed Blob inputs with separate initialisation, warm-up, and sample ranges", async () => {
const f = fixture();
const result = await runReviewHarnessFileContentHashBenchmark(f.operations, { now: f.now }, f.yieldControl);
const report = result.observations.join("\n");
expect(result.status).toBe("passed");
expect(result.detail).toBe("File content hash measurements completed.");
expect([...f.inputs().contentBySize.keys()]).toEqual(
REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS.map(({ bytes }) => bytes)
);
expect(Object.fromEntries(f.inputs().callCounts)).toEqual({
"SHA-256:4096": 4,
"xxHash64:4096": 4,
...Object.fromEntries(
REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS.slice(1).flatMap(({ bytes }) => [
[`SHA-256:${bytes}`, 3],
[`xxHash64:${bytes}`, 3],
])
),
});
expect(f.inputs().yieldCount).toBe(30);
expect(f.inputs().maximumConcurrentCalls).toBe(1);
for (const { bytes } of REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS) {
for (const algorithm of ["SHA-256", "xxHash64"]) {
expect(f.inputs().observedInputs.get(`${algorithm}:${bytes}`)?.every(
(content) => content === f.inputs().contentBySize.get(bytes)
)).toBe(true);
}
}
expect(report).toContain("xxHash64 engine initialisation: 12.50 ms; measured separately.");
expect(report).toContain(
"Warm-up on 4 KiB, one call per algorithm: SHA-256=100.00 ms; xxHash64=200.00 ms; excluded from samples."
);
expect(report).toContain("SHA-256, 4 KiB: median=3.00 ms; range=1.00–5.00 ms.");
expect(report).toContain("xxHash64, 4 KiB: median=13.00 ms; range=11.00–15.00 ms.");
expect(report).toContain("Each algorithm has three samples per input.");
expect(report).not.toContain("sha256:4096");
expect(report).not.toContain("4096n");
});
it("uses the same deterministic byte sequence for short and long inputs", async () => {
const shortInput = createFixedFileContentHashInput(4 * 1024);
const longInput = createFixedFileContentHashInput(64 * 1024);
expect(shortInput.size).toBe(4 * 1024);
expect(longInput.size).toBe(64 * 1024);
expect(new Uint8Array(await longInput.slice(0, shortInput.size).arrayBuffer())).toEqual(
new Uint8Array(await shortInput.arrayBuffer())
);
});
it("propagates hash failures without turning a partial run into a passed measurement", async () => {
const f = fixture();
const operations = {
...f.operations,
sha256: async () => {
throw new Error("synthetic hashing failure");
},
};
await expect(
runReviewHarnessFileContentHashBenchmark(operations, { now: f.now }, f.yieldControl)
).rejects.toThrow("synthetic hashing failure");
});
});
@@ -0,0 +1,20 @@
import { computeFileContentHash } from "@vrtmrz/livesync-commonlib/hashing";
import { xxhashNew } from "octagonal-wheels/hash/xxhash.js";
import {
createFixedFileContentHashInput,
type FileContentHashBenchmarkOperations,
} from "./reviewHarnessFileContentHashBenchmark";
export async function createFileContentHashBenchmarkOperations(
clock: Pick<Performance, "now"> = performance
): Promise<FileContentHashBenchmarkOperations> {
const started = clock.now();
const xxHash = await xxhashNew();
const xxHashInitialisationMs = clock.now() - started;
return {
xxHashInitialisationMs,
createInput: createFixedFileContentHashInput,
sha256: (content) => computeFileContentHash(content),
xxHash64: async (content) => xxHash.h64Raw(new Uint8Array(await content.arrayBuffer())),
};
}
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { createFileContentHashBenchmarkOperations } from "./reviewHarnessFileContentHashBenchmarkRuntime";
describe("Review Harness file content hash benchmark adapter", () => {
it("uses the packaged SHA-256 helper and xxHash64 engine with fixed Blob content", async () => {
const operations = await createFileContentHashBenchmarkOperations();
const content = operations.createInput(4 * 1024);
expect(operations.xxHashInitialisationMs).toBeGreaterThanOrEqual(0);
expect(await operations.sha256(content)).toMatch(/^sha256:/u);
expect(typeof (await operations.xxHash64(content))).toBe("bigint");
expect(await operations.sha256(content)).toBe(await operations.sha256(content));
expect(await operations.xxHash64(content)).toBe(await operations.xxHash64(content));
});
});
+18 -2
View File
@@ -1,4 +1,5 @@
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
import { LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps";
import { setGetLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
setGetLanguage(getLanguage);
@@ -51,6 +52,8 @@ import { useInteractiveConflictResolutionFeature } from "./serviceFeatures/inter
import { ConflictResolveModal } from "./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
import { useObsidianReplicationRibbonFeature } from "./serviceFeatures/obsidianReplicationRibbon.ts";
import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle";
import { useJoinedDevicesFeature } from "./serviceFeatures/useJoinedDevices";
import { useDatabaseDiagnosticsFeature } from "./serviceFeatures/useDatabaseDiagnostics";
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
export default class ObsidianLiveSyncPlugin extends Plugin {
core: LiveSyncCore;
@@ -112,6 +115,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
});
const rebuilder = new ServiceRebuilder({
path: services.path,
events: services.context.events,
API: services.API,
database: services.database,
@@ -184,12 +188,18 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
core,
(_compatibilityReplicator, p2p) => createInteractiveP2PReplication(p2p),
createOpenRebuildUI(this.app),
{ prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)) }
{
prepareP2PSettings: useP2PSettingsPreparation(
core.services.API.webCompatFetch.bind(core.services.API)
),
}
);
setupManager.registerP2PSetupConnectionProbe(replicator.connectionProbe);
useP2PReplicatorCommands(core, replicator);
useP2PReplicatorUI(core, core, replicator, createInteractiveP2PReplication(replicator));
useObsidianReplicationRibbonFeature(core);
useDatabaseDiagnosticsFeature(core);
useJoinedDevicesFeature(core, serviceHub.context.noticeGroups);
useRemoteConfiguration(core);
useSetupProtocolFeature(core, setupManager);
@@ -224,6 +234,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
void this._startUp();
}
override onunload(): void {
return void this.core.services.control.onUnload();
void this.core.services.control.onUnload().catch((error) => {
this.core.services.API.addLog(
"Failed to unload Self-hosted LiveSync: " + String(error),
LOG_LEVEL_URGENT,
"shutdown"
);
});
}
}
@@ -16,11 +16,12 @@ import { AbstractModule } from "@/modules/AbstractModule.ts";
import { $msg, translateIfAvailable } from "@/common/translation";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import type { LiveSyncCore } from "@/main.ts";
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { REMOTE_COUCHDB, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
import {
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
REMOTE_RESOURCE_KINDS,
usesEncryptedInternalMetadata,
type ReplicationAttemptFailure,
type ReplicatorInstance,
} from "@vrtmrz/livesync-commonlib/replication";
@@ -62,10 +63,14 @@ function definedTweaks(values: TweakValues): TweakValues {
);
}
function settingsAfterAdoption(assessment: TweakAssessment, direction: "adoptPreferred" | "adoptCurrent"): TweakValues {
function settingsAfterAdoption(
assessment: TweakAssessment,
direction: "adoptPreferred" | "adoptCurrent",
currentSettings: TweakValues = assessment.currentValues
): TweakValues {
const comparedKeys = new Set<string>(assessment.entries.map((entry) => entry.key));
const source = direction === "adoptPreferred" ? assessment.preferredValues : assessment.currentValues;
const target = direction === "adoptPreferred" ? assessment.currentValues : assessment.preferredValues;
const target = direction === "adoptPreferred" ? currentSettings : assessment.preferredValues;
const recommendations = Object.fromEntries(
Object.entries(source).filter(([key, value]) => !comparedKeys.has(key) && value !== undefined)
);
@@ -100,7 +105,9 @@ function resolutionSettingsSignature(settings: ObsidianLiveSyncSettings): string
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
private requiresIdConfigurationReview(assessment: TweakAssessment): boolean {
if (!assessment.entries.some(({ key, relation }) => key === "idDerivationVersion" && relation === "different")) {
if (
!assessment.entries.some(({ key, relation }) => key === "idDerivationVersion" && relation === "different")
) {
return false;
}
Logger(
@@ -208,7 +215,17 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
): Promise<[TweakValues | boolean, boolean]> {
if (assessment.alignment === "matched") return [false, false];
if (this.requiresIdConfigurationReview(assessment)) return [false, false];
const acceptedSettings = settingsAfterAdoption(assessment, "adoptPreferred");
// Retain saved preferences only when they leave the adopted effective values unchanged.
const assessedSettings = settingsAfterAdoption(assessment, "adoptPreferred");
const acceptedSettings = settingsAfterAdoption(assessment, "adoptPreferred", this.settings);
if (
this.settings.remoteType === REMOTE_COUCHDB &&
usesEncryptedInternalMetadata({ ...this.settings, ...acceptedSettings }) !==
usesEncryptedInternalMetadata({ ...this.settings, ...assessedSettings })
) {
// Adopting encryption prerequisites can activate a previously inactive preference.
acceptedSettings.encryptInternalMetadata = assessedSettings.encryptInternalMetadata;
}
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(assessment);
if (autoAcceptSide === "REMOTE") return [acceptedSettings, false];
if (autoAcceptSide === "CURRENT") return [true, false];
@@ -279,8 +296,15 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
const signature = resolutionSettingsSignature(this.settings);
const publication = await this.services.replicator.acquireActiveReplicatorContext();
if (resolutionSettingsSignature(this.settings) !== signature) return "IGNORE";
const currentTweaks = JSON.stringify(extractObject(TweakValuesTemplate, this.settings));
if (JSON.stringify(extractObject(TweakValuesTemplate, assessment.currentValues)) !== currentTweaks) {
const currentTweaks = extractObject(TweakValuesTemplate, this.settings);
const effectiveTweaks = {
...currentTweaks,
encryptInternalMetadata:
this.settings.remoteType === REMOTE_COUCHDB && usesEncryptedInternalMetadata(this.settings),
};
const assessedTweaks = JSON.stringify(extractObject(TweakValuesTemplate, assessment.currentValues));
// Replication supplies effective values; legacy callers can supply saved preferences.
if (assessedTweaks !== JSON.stringify(currentTweaks) && assessedTweaks !== JSON.stringify(effectiveTweaks)) {
return "IGNORE";
}
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(
@@ -14,6 +14,7 @@ import {
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
REMOTE_RESOURCE_KINDS,
USER_INITIATED_REPLICATION_AUTHORITY,
usesEncryptedInternalMetadata,
type ReplicationAttemptFailure,
} from "@vrtmrz/livesync-commonlib/replication";
@@ -74,6 +75,170 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
}
describe("ModuleResolvingMismatchedTweaks", () => {
it.each([
{ label: "E2EE", encrypt: false, usePathObfuscation: true },
{ label: "Property Encryption", encrypt: true, usePathObfuscation: false },
])(
"uses the remote Metadata encryption preference when adoption enables $label",
async ({ encrypt, usePathObfuscation }) => {
const { module, core, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: false,
encrypt,
usePathObfuscation,
E2EEAlgorithm: "v2",
encryptInternalMetadata: true,
idDerivationVersion: 0,
});
const current = {
...extractObject(TweakValuesTemplate, core.settings),
encryptInternalMetadata: usesEncryptedInternalMetadata(core.settings),
};
const preferred = { ...current, encrypt: true, usePathObfuscation: true, encryptInternalMetadata: false };
const assessment = assessTweakCompatibility(current, preferred);
core._services.tweakValue = {
checkAndAskResolvingMismatched: module._checkAndAskResolvingMismatchedTweaks.bind(module),
};
core.rebuilder = { $fetchLocal: vi.fn(async () => undefined) };
askSelectStringDialogue.mockResolvedValueOnce("Apply settings to this device, and fetch again");
const updateRemote = vi.fn(async () => true);
await expect(module._askResolvingMismatchedTweaks(preferred, updateRemote, assessment)).resolves.toBe(
"CHECKAGAIN"
);
expect(core.rebuilder.$fetchLocal).toHaveBeenCalledOnce();
expect(core.settings).toMatchObject({ encrypt: true, usePathObfuscation: true });
expect(usesEncryptedInternalMetadata(core.settings)).toBe(false);
expect(updateRemote).toHaveBeenCalledWith(
expect.objectContaining({ encrypt: true, usePathObfuscation: true, encryptInternalMetadata: false })
);
}
);
it.each([
{ label: "automatic alignment with E2EE disabled", autoAcceptCompatibleTweak: true, encrypt: false },
{ label: "manual review with E2EE disabled", autoAcceptCompatibleTweak: false, encrypt: false },
{
label: "automatic alignment with Property Encryption disabled",
autoAcceptCompatibleTweak: true,
encrypt: true,
},
])(
"resolves an effective chunk mismatch while retaining an inactive encryption preference: $label",
async ({ autoAcceptCompatibleTweak, encrypt }) => {
const { module, core, askSelectStringDialogue, reinitialise } = createModule({
autoAcceptCompatibleTweak,
encrypt,
usePathObfuscation: false,
encryptInternalMetadata: true,
customChunkSize: 60,
tweakModified: 100,
});
// CouchDB assesses the effective encryption value, while the saved preference remains enabled.
const current = { ...extractObject(TweakValuesTemplate, core.settings), encryptInternalMetadata: false };
const preferred = { ...current, customChunkSize: 0, tweakModified: 200 };
const assessment = assessTweakCompatibility(current, preferred);
core._services.tweakValue = {
checkAndAskResolvingMismatched: module._checkAndAskResolvingMismatchedTweaks.bind(module),
};
askSelectStringDialogue.mockResolvedValueOnce("Apply settings to this device");
const updateRemote = vi.fn(async () => true);
await expect(module._askResolvingMismatchedTweaks(preferred, updateRemote, assessment)).resolves.toBe(
"CHECKAGAIN"
);
expect(core.settings).toMatchObject({ customChunkSize: 0, encryptInternalMetadata: true });
expect(core._services.setting.saveSettingData).toHaveBeenCalledOnce();
expect(reinitialise).toHaveBeenCalledOnce();
expect(updateRemote).toHaveBeenCalledOnce();
expect(askSelectStringDialogue).toHaveBeenCalledTimes(autoAcceptCompatibleTweak ? 0 : 1);
}
);
it.each(["settings", "publication"] as const)(
"discards an effective assessment when %s change while awaiting a decision",
async (changed) => {
const { module, core, askSelectStringDialogue, reinitialise } = createModule({
autoAcceptCompatibleTweak: false,
encrypt: false,
encryptInternalMetadata: true,
customChunkSize: 60,
});
const current = { ...extractObject(TweakValuesTemplate, core.settings), encryptInternalMetadata: false };
const preferred = { ...current, customChunkSize: 0 };
const assessment = assessTweakCompatibility(current, preferred);
core._services.tweakValue = {
checkAndAskResolvingMismatched: module._checkAndAskResolvingMismatchedTweaks.bind(module),
};
askSelectStringDialogue.mockImplementation(async () => {
if (changed === "settings") {
// The effective value stays false, but the user's saved preference has changed.
core.settings.encryptInternalMetadata = false;
} else {
core._services.replicator.acquireActiveReplicatorContext.mockResolvedValue({});
}
return "Apply settings to this device";
});
const updateRemote = vi.fn(async () => true);
await expect(module._askResolvingMismatchedTweaks(preferred, updateRemote, assessment)).resolves.toBe(
"IGNORE"
);
expect(askSelectStringDialogue).toHaveBeenCalledOnce();
expect(core.settings.customChunkSize).toBe(60);
expect(core._services.setting.saveSettingData).not.toHaveBeenCalled();
expect(reinitialise).not.toHaveBeenCalled();
expect(updateRemote).not.toHaveBeenCalled();
}
);
it("discards an effective assessment of an earlier chunk setting before opening a dialogue", async () => {
const { module, core, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: false,
encrypt: false,
encryptInternalMetadata: true,
customChunkSize: 60,
});
const current = { ...extractObject(TweakValuesTemplate, core.settings), encryptInternalMetadata: false };
const preferred = { ...current, customChunkSize: 0 };
const assessment = assessTweakCompatibility(current, preferred);
core.settings.customChunkSize = 72;
core._services.tweakValue = {
checkAndAskResolvingMismatched: module._checkAndAskResolvingMismatchedTweaks.bind(module),
};
const updateRemote = vi.fn(async () => true);
await expect(module._askResolvingMismatchedTweaks(preferred, updateRemote, assessment)).resolves.toBe("IGNORE");
expect(askSelectStringDialogue).not.toHaveBeenCalled();
expect(core.settings.customChunkSize).toBe(72);
expect(core._services.setting.saveSettingData).not.toHaveBeenCalled();
expect(updateRemote).not.toHaveBeenCalled();
});
it("applies an explicit decision when effective internal Metadata encryption differs", async () => {
const { module, core, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: true,
encrypt: true,
usePathObfuscation: true,
E2EEAlgorithm: "v2",
encryptInternalMetadata: true,
});
const preferred = { ...extractObject(TweakValuesTemplate, core.settings), encryptInternalMetadata: false };
core._services.tweakValue = {
checkAndAskResolvingMismatched: module._checkAndAskResolvingMismatchedTweaks.bind(module),
};
askSelectStringDialogue.mockResolvedValueOnce("Apply settings to this device");
await expect(module._askResolvingMismatchedTweaks(preferred, async () => true)).resolves.toBe("CHECKAGAIN");
expect(askSelectStringDialogue).toHaveBeenCalledOnce();
expect(core.settings.encryptInternalMetadata).toBe(false);
expect(core._services.setting.saveSettingData).toHaveBeenCalledOnce();
});
it.each([0, 1] as const)(
"keeps ID configuration %s when automatically aligning Chunk settings",
async (idDerivationVersion) => {
@@ -230,7 +230,13 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
}
this.isLastHidden = isHidden;
await this.services.fileProcessing.commitPendingFileEvents();
const pendingFileEventsCommitted = await this.services.fileProcessing.commitPendingFileEvents();
if (!pendingFileEventsCommitted) {
this._log(
"Pending file events remain because their commit did not complete before the window visibility changed.",
LOG_LEVEL_NOTICE
);
}
// Desktop opt-in (LiveSync/Periodic only): keep the background channel running while the
// window is hidden, instead of suspending on hide. On hide we skip the suspend for both
@@ -287,7 +293,10 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
if (this.settings.suspendFileWatching) return;
if (!this.settings.isConfigured) return;
if (!this.services.appLifecycle.isReady()) return;
await this.services.fileProcessing.commitPendingFileEvents();
const pendingFileEventsCommitted = await this.services.fileProcessing.commitPendingFileEvents();
if (!pendingFileEventsCommitted) {
return;
}
if (file == null) {
return;
}
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import { ModuleObsidianEvents } from "./ModuleObsidianEvents";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
type SetupOptions = {
settings?: Partial<typeof DEFAULT_SETTINGS>;
@@ -23,6 +24,8 @@ function setup(opts: SetupOptions) {
onResumed: vi.fn(async () => true),
};
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
const replication = { replicateUnattendedByEvent: vi.fn(async () => undefined) };
const conflict = { queueCheckForIfOpen: vi.fn(async () => undefined) };
const boundedRemoteActivityCount = reactiveSource(0);
const boundedLocalApplicationActivityCount = reactiveSource(0);
@@ -39,6 +42,8 @@ function setup(opts: SetupOptions) {
setting: { saveSettingData: vi.fn(async () => undefined) },
appLifecycle,
fileProcessing,
replication,
conflict,
replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount },
},
settings: {
@@ -61,6 +66,9 @@ function setup(opts: SetupOptions) {
module,
appLifecycle,
fileProcessing,
replication,
conflict,
api: core._services.API,
boundedRemoteActivityCount,
boundedLocalApplicationActivityCount,
};
@@ -303,6 +311,23 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
});
it("logs a failed pending-file commit and still suspends on hide", async () => {
const { module, appLifecycle, fileProcessing, api } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: true },
hidden: true,
});
fileProcessing.commitPendingFileEvents.mockResolvedValue(false);
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
expect(api.addLog).toHaveBeenCalledWith(
expect.stringContaining("Pending file events remain"),
LOG_LEVEL_NOTICE,
expect.any(String)
);
});
it("does not apply on mobile even if the flag is set", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: true, liveSync: true },
@@ -313,3 +338,20 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
});
});
describe("watchWorkspaceOpenAsync — pending file event commit", () => {
it("skips replication and conflict checks when pending file events fail to commit", async () => {
const { module, fileProcessing, replication, conflict } = setup({
settings: { syncOnFileOpen: true },
hidden: false,
});
fileProcessing.commitPendingFileEvents.mockResolvedValue(false);
await module.watchWorkspaceOpenAsync({ path: "note.md" } as Parameters<
typeof module.watchWorkspaceOpenAsync
>[0]);
expect(replication.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(conflict.queueCheckForIfOpen).not.toHaveBeenCalled();
});
});
+61 -68
View File
@@ -4,7 +4,6 @@ import {
LOG_LEVEL_INFO,
LOG_LEVEL_VERBOSE,
PREFIXMD_LOGFILE,
type DatabaseConnectingStatus,
type LOG_LEVEL,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { scheduleTask } from "octagonal-wheels/concurrency/task";
@@ -31,16 +30,14 @@ import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
import { serialized } from "octagonal-wheels/concurrency/lock";
import { $msg } from "@/common/translation";
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
import {
REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS,
formatRemoteActivityStatusLabel,
getTrackedRequestCount,
} from "./RemoteActivityStatus.ts";
import { REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS, getTrackedRequestCount } from "./RemoteActivityStatus.ts";
import {
createChunkFetchCounterLabel,
createMinimumVisibleActivityCount,
createPaddedCounterLabel,
} from "./StatusBarDisplay.ts";
import { createFastForwardCheckedChangesDisplay, projectReplicationStatusLabel } from "./ReplicationStatusLabel.ts";
import { createStatusBarRenderer, type StatusBarPart, type StatusBarRenderer } from "./StatusBarRenderer.ts";
import type { LiveSyncCore } from "@/main.ts";
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
import { isValidPath } from "@/common/utils.ts";
@@ -124,7 +121,14 @@ export class ModuleLog extends AbstractObsidianModule {
logHistory?: HTMLDivElement;
messageArea?: HTMLDivElement;
statusBarLabels!: ReactiveValue<{ message: string; status: string }>;
statusBarRenderer?: StatusBarRenderer;
statusLineRenderer?: StatusBarRenderer;
statusBarLabels!: ReactiveValue<{
message: string;
status: string;
parts: readonly StatusBarPart[];
statusBarParts: readonly StatusBarPart[];
}>;
statusLog = reactiveSource("");
activeFileStatus = reactiveSource("");
notifies: { [key: string]: { count: number } } = {};
@@ -164,79 +168,54 @@ export class ModuleLog extends AbstractObsidianModule {
);
const requestingStatLabel = computed(() => {
return formatRemoteActivityStatusLabel({
return {
remoteOperationCount: Math.max(0, this.services.replicator.boundedRemoteActivityCount.value),
trackedRequestCount: displayedTrackedRequestCount.value,
});
};
});
const replicationStatLabel = computed(() => {
const e = this.services.replicator.replicationStatics.value;
const sent = e.sent;
const arrived = e.arrived;
const maxPullSeq = e.maxPullSeq;
const maxPushSeq = e.maxPushSeq;
const lastSyncPullSeq = e.lastSyncPullSeq;
const lastSyncPushSeq = e.lastSyncPushSeq;
let pushLast = "";
let pullLast = "";
let w = "";
const labels: Partial<Record<DatabaseConnectingStatus, string>> = {
CONNECTED: "⚡",
JOURNAL_SEND: "📦↑",
JOURNAL_RECEIVE: "📦↓",
};
switch (e.syncStatus) {
case "CLOSED":
case "COMPLETED":
case "NOT_CONNECTED":
w = "⏹";
break;
case "STARTED":
w = "🌀";
break;
case "PAUSED":
w = "💤";
break;
case "CONNECTED":
case "JOURNAL_SEND":
case "JOURNAL_RECEIVE":
w = labels[e.syncStatus] || "⚡";
pushLast =
lastSyncPushSeq == 0
? ""
: lastSyncPushSeq >= maxPushSeq
? " (LIVE)"
: ` (${maxPushSeq - lastSyncPushSeq})`;
pullLast =
lastSyncPullSeq == 0
? ""
: lastSyncPullSeq >= maxPullSeq
? " (LIVE)"
: ` (${maxPullSeq - lastSyncPullSeq})`;
break;
case "ERRORED":
w = "⚠";
break;
default:
w = "?";
}
return { w, sent, pushLast, arrived, pullLast };
});
const fastForwardCount = registerDisplay(
createFastForwardCheckedChangesDisplay(this.services.replicator.replicationStatics)
);
const replicationStatLabel = computed(() =>
projectReplicationStatusLabel(
this.services.replicator.replicationStatics.value,
requestingStatLabel(),
fastForwardCount.value
)
);
const labelProc = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.processing, `⏳`));
const labelPend = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.totalQueued, `🛫`));
const labelInBatchDelay = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.batched, `📬`));
const waitingLabel = computed(() => {
return `${labelProc.value}${labelPend.value}${labelInBatchDelay.value}`;
});
const counterPart = (key: string, mark: string, value: number | undefined): StatusBarPart[] =>
value === undefined ? [] : [{ key, text: " " + mark, value }];
const statusLineLabel = computed(() => {
const { w, sent, pushLast, arrived, pullLast } = replicationStatLabel();
const replication = replicationStatLabel();
const queued = queueCountLabel();
const waiting = waitingLabel();
const networkActivity = requestingStatLabel();
const p2p = this.p2pLogCollector.p2pReplicationLine.value;
const firstLineParts = [
...replication.parts,
...counterPart("file-processing", "⏳", labelProc.count.value),
...counterPart("file-pending", "🛫", labelPend.count.value),
...counterPart("file-batched", "📬", labelInBatchDelay.count.value),
...counterPart("queue-replication", "📥", labelReplication.count.value),
...counterPart("queue-database", "📄", labelDBCount.count.value),
...counterPart("queue-storage", "💾", labelStorageCount.count.value),
...counterPart("queue-chunk-initial", "🛄", labelChunkCount.initialCount.value),
...counterPart("queue-chunk-retrying", "🔁", labelChunkCount.retryingCount.value),
...counterPart("queue-plugin-scan", "🔌", labelPluginScanCount.count.value),
...counterPart("queue-hidden-files", "⚙️", labelHiddenFilesCount.count.value),
...counterPart("queue-conflicts", "🔩", labelConflictProcessCount.count.value),
];
return {
message: `${networkActivity}Sync: ${w} ↑ ${sent}${pushLast} ↓ ${arrived}${pullLast}${waiting}${queued}${p2p == "" ? "" : "\n" + p2p}`,
message: replication.message + waiting + queued + (p2p === "" ? "" : "\n" + p2p),
parts: p2p === "" ? firstLineParts : [...firstLineParts, { key: "p2p", text: "\n" + p2p }],
statusBarParts: firstLineParts,
hasP2P: p2p !== "",
};
});
@@ -244,13 +223,16 @@ export class ModuleLog extends AbstractObsidianModule {
const scheduleMessage = this.services.appLifecycle.isReloadingScheduled()
? `WARNING! RESTARTING OBSIDIAN IS SCHEDULED!\n`
: "";
const { message } = statusLineLabel();
const { message, parts, statusBarParts, hasP2P } = statusLineLabel();
const fileStatus = this.activeFileStatus.value;
const status = scheduleMessage + this.statusLog.value;
const fileStatusIcon = `${fileStatus && this.settings.hideFileWarningNotice ? " ⛔ SKIP" : ""}`;
const skipPart = fileStatusIcon === "" ? [] : [{ key: "file-skip", text: fileStatusIcon }];
return {
message: `${message}${fileStatusIcon}`,
status,
parts: [...parts, ...skipPart],
statusBarParts: hasP2P ? statusBarParts : [...statusBarParts, ...skipPart],
};
});
this.statusBarLabels = statusBarLabels;
@@ -391,7 +373,8 @@ export class ModuleLog extends AbstractObsidianModule {
newLog = newLog.substring(moduleTagEnd + MARK_LOG_SEPARATOR.length + 1);
}
this.statusBar?.setText(newMsg.split("\n")[0]);
const { parts, statusBarParts } = this.statusBarLabels.value;
this.statusBarRenderer?.render(statusBarParts);
if (this.statusDiv) {
this.statusDiv.setCssStyles({ display: this.settings?.showStatusOnEditor ? "" : "none" });
}
@@ -409,7 +392,7 @@ export class ModuleLog extends AbstractObsidianModule {
const recentLogs = recent.reverse().join("\n");
if (isDirty("recentLogs", recentLogs)) this.logHistory!.innerText = recentLogs;
}
if (isDirty("newMsg", newMsg)) this.statusLine!.innerText = newMsg;
if (isDirty("newMsg", newMsg)) this.statusLineRenderer?.render(parts);
if (isDirty("newLog", newLog)) this.logMessage!.innerText = newLog;
} else {
// const root = activeDocument.documentElement;
@@ -505,6 +488,16 @@ ${stringifyYaml(info)}
this.statusBar = this.services.API.addStatusBarItem();
this.statusBar?.addClass("syncstatusbar");
}
if (this.statusLine) {
const renderer = createStatusBarRenderer(this.statusLine);
this.statusLineRenderer = renderer;
this.plugin.register(() => renderer.dispose());
}
if (this.statusBar) {
const renderer = createStatusBarRenderer(this.statusBar);
this.statusBarRenderer = renderer;
this.plugin.register(() => renderer.dispose());
}
this.adjustStatusDivPosition();
this._log("Log module loaded", LOG_LEVEL_INFO);
this._log("Verbose log", LOG_LEVEL_VERBOSE);
@@ -8,13 +8,13 @@ import { openObsidianSettings } from "@/common/obsidianSettings.ts";
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
settingTab!: ObsidianLiveSyncSettingTab;
_everyOnloadAfterLoadSettings(): Promise<boolean> {
async _everyOnloadAfterLoadSettings(): Promise<boolean> {
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
this.settingTab.reloadAllSettings(true);
await this.settingTab.loadSettings();
this.plugin.addSettingTab(this.settingTab);
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting());
return Promise.resolve(true);
return true;
}
openSetting() {
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const settingTabState = vi.hoisted(() => ({
callOrder: [] as string[],
reloadAllSettings: vi.fn<(skipUpdate?: boolean) => void>(),
loadSettings: vi.fn<() => Promise<void>>(),
}));
const eventHubState = vi.hoisted(() => ({
@@ -11,9 +11,9 @@ const eventHubState = vi.hoisted(() => ({
vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({
ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab {
reloadAllSettings(skipUpdate?: boolean) {
settingTabState.callOrder.push(`reload:${String(skipUpdate)}`);
settingTabState.reloadAllSettings(skipUpdate);
async loadSettings() {
await settingTabState.loadSettings();
settingTabState.callOrder.push("settings-loaded");
}
},
}));
@@ -65,7 +65,7 @@ function createModuleHarness() {
describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
beforeEach(() => {
settingTabState.callOrder.length = 0;
settingTabState.reloadAllSettings.mockClear();
settingTabState.loadSettings.mockReset().mockResolvedValue(undefined);
eventHubState.onEvent.mockClear();
});
@@ -79,13 +79,29 @@ describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
});
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();
expect(handler).toBeTypeOf("function");
await handler!();
const loading = handler!();
expect(plugin.addSettingTab).not.toHaveBeenCalled();
finishRead();
await loading;
expect(settingTabState.reloadAllSettings).toHaveBeenCalledWith(true);
expect(settingTabState.callOrder).toEqual(["reload:true", "add-setting-tab"]);
expect(settingTabState.loadSettings).toHaveBeenCalledOnce();
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();
});
});
@@ -0,0 +1,209 @@
import { reactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
import type { ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import type { DisposableReactiveValue } from "./StatusBarDisplay.ts";
import type { StatusBarPart } from "./StatusBarRenderer.ts";
import {
formatRemoteActivityStatusLabel,
REMOTE_OPERATION_ACTIVITY_ICON,
REMOTE_REQUEST_ACTIVITY_ICON,
type RemoteActivityStatus,
} from "./RemoteActivityStatus.ts";
export const FAST_FORWARD_COUNT_LINGER_MS = 3_000;
function readPositiveCheckedChanges(source: ReactiveValue<ReplicationStatics>): number | undefined {
const count = source.value.couchDB?.pull?.checkedChanges;
return typeof count === "number" && Number.isFinite(count) && count > 0 ? count : undefined;
}
/**
* Briefly displays changing CouchDB pull-feed comparison counts.
*
* Unrelated replication-stat changes do not extend the linger or redisplay an
* expired count. A positive initial snapshot is displayed to late subscribers.
*/
export function createFastForwardCheckedChangesDisplay(
source: ReactiveValue<ReplicationStatics>,
lingerMs = FAST_FORWARD_COUNT_LINGER_MS
): DisposableReactiveValue<number | undefined> {
const displayed = reactiveSource<number | undefined>(undefined);
const linger = Math.max(0, lingerMs);
let lastObserved = readPositiveCheckedChanges(source);
let clearTimer: number | undefined;
let disposed = false;
const cancelClear = () => {
if (clearTimer === undefined) return;
compatGlobal.clearTimeout(clearTimer);
clearTimer = undefined;
};
const scheduleClear = () => {
clearTimer = compatGlobal.setTimeout(() => {
clearTimer = undefined;
if (disposed) return;
if (readPositiveCheckedChanges(source) !== lastObserved) {
update();
return;
}
displayed.value = undefined;
}, linger);
};
const update = () => {
if (disposed) return;
const nextCount = readPositiveCheckedChanges(source);
if (nextCount === lastObserved) return;
lastObserved = nextCount;
cancelClear();
if (nextCount === undefined) {
displayed.value = undefined;
return;
}
displayed.value = nextCount;
scheduleClear();
};
if (lastObserved !== undefined) {
displayed.value = lastObserved;
scheduleClear();
}
source.onChanged(update);
return {
get value() {
return displayed.value;
},
onChanged(handler) {
displayed.onChanged(handler);
},
offChanged(handler) {
displayed.offChanged(handler);
},
dispose() {
if (disposed) return;
disposed = true;
cancelClear();
source.offChanged(update);
},
};
}
export type ReplicationStatusProjection = {
icon: string;
message: string;
parts: StatusBarPart[];
};
function remainingSequenceLabel(
key: string,
lastSyncSeq: number,
maxSeq: number
): { text: string; part?: StatusBarPart } {
if (lastSyncSeq === 0) return { text: "" };
if (lastSyncSeq >= maxSeq) return { text: " (LIVE)", part: { key, text: " (LIVE)" } };
const remaining = maxSeq - lastSyncSeq;
return {
text: " (" + remaining + ")",
part: { key, text: " (", value: remaining, suffix: ")" },
};
}
/** Projects the existing replication labels into reusable numeric status items. */
export function projectReplicationStatusLabel(
stats: ReplicationStatics,
networkStatus: RemoteActivityStatus,
fastForwardCount?: number
): ReplicationStatusProjection {
const { sent, arrived, maxPullSeq, maxPushSeq, lastSyncPullSeq, lastSyncPushSeq } = stats;
const networkActivity = formatRemoteActivityStatusLabel(networkStatus);
let pushRemaining: { text: string; part?: StatusBarPart } = { text: "" };
let pullRemaining: { text: string; part?: StatusBarPart } = { text: "" };
let icon: string;
switch (stats.syncStatus) {
case "CLOSED":
case "COMPLETED":
case "NOT_CONNECTED":
icon = "⏹";
break;
case "STARTED":
icon = "🌀";
break;
case "PAUSED":
icon = "💤";
break;
case "CONNECTED":
case "JOURNAL_SEND":
case "JOURNAL_RECEIVE":
icon = stats.syncStatus === "JOURNAL_SEND" ? "📦↑" : stats.syncStatus === "JOURNAL_RECEIVE" ? "📦↓" : "⚡";
pushRemaining = remainingSequenceLabel("push-remaining", lastSyncPushSeq, maxPushSeq);
pullRemaining = remainingSequenceLabel("pull-remaining", lastSyncPullSeq, maxPullSeq);
break;
case "ERRORED":
icon = "⚠";
break;
default:
icon = "?";
}
const couchDB = stats.couchDB;
const isFastForwardChecking =
stats.syncStatus !== "ERRORED" &&
couchDB !== undefined &&
couchDB.phase !== "idle" &&
(couchDB.pull?.checking === true || couchDB.push?.checking === true);
if (isFastForwardChecking) icon = "⏩";
const visibleFastForwardCount =
couchDB !== undefined &&
typeof fastForwardCount === "number" &&
Number.isFinite(fastForwardCount) &&
fastForwardCount > 0
? fastForwardCount
: undefined;
const fastForwardLabel = visibleFastForwardCount === undefined ? "" : " (⏩ " + visibleFastForwardCount + ")";
const parts: StatusBarPart[] = [];
if (networkStatus.trackedRequestCount > 0) {
parts.push({
key: "network",
text:
(networkStatus.remoteOperationCount > 0 ? `${REMOTE_OPERATION_ACTIVITY_ICON} ` : "") +
REMOTE_REQUEST_ACTIVITY_ICON,
value: networkStatus.trackedRequestCount,
suffix: " ",
});
} else if (networkActivity !== "") {
parts.push({ key: "network", text: networkActivity });
}
parts.push({ key: "sync", text: "Sync: " + icon });
if (visibleFastForwardCount !== undefined) {
parts.push({
key: "ff",
text: " (⏩ ",
value: visibleFastForwardCount,
suffix: ")",
title: "Compared CouchDB feed entries",
});
}
parts.push({ key: "sent", text: " ↑ ", value: sent });
if (pushRemaining.part !== undefined) parts.push(pushRemaining.part);
parts.push({ key: "arrived", text: " ↓ ", value: arrived });
if (pullRemaining.part !== undefined) parts.push(pullRemaining.part);
return {
icon,
message:
networkActivity +
"Sync: " +
icon +
fastForwardLabel +
" ↑ " +
sent +
pushRemaining.text +
" ↓ " +
arrived +
pullRemaining.text,
parts,
};
}
@@ -0,0 +1,242 @@
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication";
import {
FAST_FORWARD_COUNT_LINGER_MS,
createFastForwardCheckedChangesDisplay,
projectReplicationStatusLabel,
} from "./ReplicationStatusLabel.ts";
function couchDBProgress(
phase: "idle" | "one-shot" | "catch-up" | "continuous",
checkedChanges = 0,
checking = false
): NonNullable<ReplicationStatics["couchDB"]> {
return {
phase,
pull: { checkedChanges, completedBatches: 0, checking },
push: { checkedChanges: 0, completedBatches: 0, checking: false },
};
}
const noNetworkActivity = { remoteOperationCount: 0, trackedRequestCount: 0 };
function stats(overrides: Partial<ReplicationStatics> = {}): ReplicationStatics {
return {
sent: 0,
arrived: 0,
maxPullSeq: 0,
maxPushSeq: 0,
lastSyncPullSeq: 0,
lastSyncPushSeq: 0,
syncStatus: "CONNECTED",
...overrides,
};
}
describe("projectReplicationStatusLabel", () => {
it("exposes the remote-request count as a numeric status item", () => {
const projection = projectReplicationStatusLabel(stats(), { remoteOperationCount: 1, trackedRequestCount: 12 });
expect(projection.parts.find((part) => part.key === "network")).toMatchObject({
text: "📲 🌐",
value: 12,
suffix: " ",
});
});
it("preserves the connected label, directions, and remaining-sequence text", () => {
const projection = projectReplicationStatusLabel(
stats({
sent: 4,
arrived: 2,
maxPullSeq: 20,
maxPushSeq: 30,
lastSyncPullSeq: 17,
lastSyncPushSeq: 22,
}),
{ remoteOperationCount: 0, trackedRequestCount: 1 }
);
expect(projection.message).toBe("🌐1 Sync: ⚡ ↑ 4 (8) ↓ 2 (3)");
expect(projection.parts.map((part) => part.key)).toEqual([
"network",
"sync",
"sent",
"push-remaining",
"arrived",
"pull-remaining",
]);
expect(projection.parts[2]).toMatchObject({ key: "sent", text: " ↑ ", value: 4 });
expect(projection.parts[3]).toMatchObject({ key: "push-remaining", text: " (", value: 8, suffix: ")" });
expect(projection.parts[4]).toMatchObject({ key: "arrived", text: " ↓ ", value: 2 });
expect(projection.parts[5]).toMatchObject({ key: "pull-remaining", text: " (", value: 3, suffix: ")" });
});
it("keeps the existing status icons when CouchDB is idle or not present", () => {
const statuses = [
["CLOSED", "⏹"],
["COMPLETED", "⏹"],
["NOT_CONNECTED", "⏹"],
["STARTED", "🌀"],
["PAUSED", "💤"],
["CONNECTED", "⚡"],
["JOURNAL_SEND", "📦↑"],
["JOURNAL_RECEIVE", "📦↓"],
["ERRORED", "⚠"],
] as const;
for (const [syncStatus, icon] of statuses) {
const idleWithCount = projectReplicationStatusLabel(
stats({ syncStatus, couchDB: couchDBProgress("idle", 19, true) }),
noNetworkActivity,
19
);
expect(idleWithCount.icon).toBe(icon);
expect(idleWithCount.parts.some((part) => part.key === "ff")).toBe(true);
const withoutCouchDB = projectReplicationStatusLabel(stats({ syncStatus }), noNetworkActivity, 19);
expect(withoutCouchDB.icon).toBe(icon);
expect(withoutCouchDB.parts.some((part) => part.key === "ff")).toBe(false);
}
const noFastForward = projectReplicationStatusLabel(stats(), noNetworkActivity);
expect(noFastForward.parts.some((part) => part.key === "ff")).toBe(false);
expect(noFastForward.message).toBe("Sync: ⚡ ↑ 0 ↓ 0");
});
it("shows fast-forward checking with zero arrivals, transferred data, and the final idle count", () => {
const checking = projectReplicationStatusLabel(
stats({
syncStatus: "PAUSED",
arrived: 0,
couchDB: couchDBProgress("catch-up", 0, true),
}),
noNetworkActivity
);
expect(checking.icon).toBe("⏩");
expect(checking.parts[2]).toMatchObject({ key: "arrived", value: 0 });
const transferred = projectReplicationStatusLabel(
stats({
arrived: 7,
couchDB: couchDBProgress("continuous", 1200, false),
}),
noNetworkActivity,
1200
);
expect(transferred.icon).toBe("⚡");
expect(transferred.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
expect(transferred.parts.map((part) => part.key)).toEqual(["sync", "ff", "sent", "arrived"]);
expect(transferred.parts[1]).toMatchObject({
key: "ff",
text: " (⏩ ",
value: 1200,
suffix: ")",
});
const idle = projectReplicationStatusLabel(
stats({
syncStatus: "CONNECTED",
arrived: 7,
couchDB: couchDBProgress("idle", 1200, false),
}),
noNetworkActivity,
1200
);
expect(idle.icon).toBe("⚡");
expect(idle.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
});
it("keeps the error icon ahead of an active CouchDB checking indicator", () => {
const projection = projectReplicationStatusLabel(
stats({
syncStatus: "ERRORED",
couchDB: {
...couchDBProgress("one-shot", 2, true),
push: { checkedChanges: 0, completedBatches: 0, checking: true },
},
}),
noNetworkActivity
);
expect(projection.icon).toBe("⚠");
});
});
describe("createFastForwardCheckedChangesDisplay", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("uses the initial snapshot, expires unchanged counts, and only reappears for new progress", () => {
const source = reactiveSource(
stats({
couchDB: couchDBProgress("catch-up", 1200, true),
})
);
const display = createFastForwardCheckedChangesDisplay(source);
expect(display.value).toBe(1200);
vi.advanceTimersByTime(1000);
source.value = stats({
sent: 3,
couchDB: couchDBProgress("idle", 1200, false),
});
expect(display.value).toBe(1200);
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS - 1001);
expect(display.value).toBe(1200);
source.value = stats({
sent: 4,
couchDB: couchDBProgress("idle", 1200, false),
});
vi.advanceTimersByTime(1);
expect(display.value).toBeUndefined();
source.value = stats({
syncStatus: "PAUSED",
couchDB: couchDBProgress("continuous", 1200, true),
});
expect(display.value).toBeUndefined();
source.value = stats({
couchDB: couchDBProgress("continuous", 1201, true),
});
expect(display.value).toBe(1201);
source.value = stats({
couchDB: couchDBProgress("one-shot", 0, true),
});
expect(display.value).toBeUndefined();
source.value = stats();
expect(display.value).toBeUndefined();
display.dispose();
});
it("clears its timer and subscription when disposed", () => {
const source = reactiveSource(stats({ couchDB: couchDBProgress("catch-up", 5, true) }));
const display = createFastForwardCheckedChangesDisplay(source);
display.dispose();
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS);
source.value = stats({ couchDB: couchDBProgress("continuous", 6, true) });
expect(display.value).toBe(5);
});
it("does not display progress for providers without CouchDB replication state", () => {
const source = reactiveSource(stats());
const display = createFastForwardCheckedChangesDisplay(source);
expect(display.value).toBeUndefined();
source.value = stats({ couchDB: { phase: "continuous" } });
expect(display.value).toBeUndefined();
display.dispose();
});
});
@@ -1,4 +1,5 @@
import { ChunkAlgorithmNames } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { $msg } from "@/common/translation";
import type { SettingSpecGroup } from "./SettingSpec.ts";
export type AdvancedSettingSpecContext = {
@@ -22,6 +23,19 @@ export function createAdvancedSettingSpecGroups({
control: { type: "dropdown", options: () => ChunkAlgorithmNames },
},
{ key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } },
{ key: "strictContentCheckOnStartup", control: { type: "toggle" } },
{
key: "pathNormalizationVersion",
holdValue: true,
control: {
type: "numeric-dropdown",
options: () => ({
"0": $msg("Legacy path identity"),
"1": $msg("Unicode NFC path identity"),
}),
},
visible: isCouchDB,
},
],
},
{
@@ -243,6 +243,29 @@ export class LiveSyncSetting extends Setting {
});
return this;
}
autoWireNumericDropDown(key: AllNumericItemKey, opt: AutoWireOption & { options: Record<string, string> }) {
const conf = this.autoWireSetting(key, opt);
this.addDropdown((dropdown) => {
this.autoWiredComponent = dropdown;
const setValue = wrapMemo((value: string) => {
dropdown.setValue(value);
});
dropdown.addOptions(opt.options);
this.invalidateValue = () => setValue(`${LiveSyncSetting.env.editingSettings[key] ?? ""}`);
this.invalidateValue();
dropdown.onChange(async (value) => {
const numericValue = Number(value);
if (!Number.isInteger(numericValue) || !Object.prototype.hasOwnProperty.call(opt.options, value)) {
return false;
}
await this.commitValue(numericValue);
});
this.autoWireComponent(this.autoWiredComponent, conf, opt);
});
return this;
}
addApplyButton(keys: AllSettingItemKey[], text?: string) {
this.addButton((button) => {
this.applyButtonComponent = button;
@@ -1,3 +1,4 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
@@ -158,6 +159,7 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
},
services: {
setting: {
deviceValues: new MemoryValueStore(),
getDeviceAndVaultName: vi.fn(() => ""),
saveSettingData: vi.fn(async () => undefined),
},
@@ -457,7 +459,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
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 changeLog = findPage(tab, "Change Log");
if (!changeLog.page) {
@@ -466,7 +468,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
changeLog.page().display();
tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode;
tab.requestReload();
await tab.requestReload();
expect(tab.update).toHaveBeenCalledOnce();
});
@@ -480,7 +482,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
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 changeLog = findPage(tab, "Change Log");
if (!changeLog.page) {
@@ -489,12 +491,12 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
changeLog.page().display();
tab.core.settings.displayLanguage = "ja";
tab.requestReload();
await tab.requestReload();
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 changeLog = findPage(tab, "Change Log");
if (!changeLog.page) {
@@ -505,7 +507,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
tab.editingSettings.usePowerUserMode = true;
tab.core.settings.usePowerUserMode = true;
tab.requestReload();
await tab.requestReload();
const configureAnchor = vi.mocked(tab.core.confirm.askInPopup).mock.calls[0]?.[2];
expect(configureAnchor).toBeTypeOf("function");
let acceptExternalSetting: (() => void) | undefined;
@@ -52,7 +52,6 @@ import {
type PageFunctions,
type UpdateFunction,
} from "./SettingPane.ts";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replication/journal/objectstore/MinioStorageAdapter";
import { closeObsidianSettings } from "@/common/obsidianSettings.ts";
@@ -100,6 +99,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
}
selectedScreen = "";
private localConfigurationPassphrase = "";
private localSettingsReadVersion = 0;
_editingSettings?: AllSettings;
// Buffered Settings for editing
get editingSettings(): AllSettings {
@@ -133,7 +135,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
applySetting(keys: AllSettingItemKey[]) {
for (const k of keys) {
if (!this.isDirty(k)) continue;
if (this.isConfigurationEncryptionSetting(k) || k in OnDialogSettingsDefault) {
if (
this.isReconstructionRequiredSetting(k) ||
this.isConfigurationEncryptionSetting(k) ||
k in OnDialogSettingsDefault
) {
// //@ts-ignore
// this.initialSettings[k] = this.editingSettings[k];
continue;
@@ -147,8 +153,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
});
}
async applyAllSettings() {
const changedKeys = (Object.keys(this.editingSettings ?? {}) as AllSettingItemKey[]).filter((e) =>
this.isDirty(e)
const changedKeys = (Object.keys(this.editingSettings ?? {}) as AllSettingItemKey[]).filter(
(e) => this.isDirty(e) && !this.isReconstructionRequiredSetting(e)
);
await this.saveConfigurationEncryptionIfDirty(changedKeys);
this.applySetting(changedKeys);
@@ -158,8 +164,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) {
if (key == "deviceAndVaultName") {
this.services.setting.setDeviceAndVaultName(this.editingSettings?.[key] ?? "");
this.services.setting.saveDeviceAndVaultName();
return await Promise.resolve();
await this.services.setting.saveDeviceAndVaultName();
}
}
/**
@@ -168,7 +173,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
*/
async saveSettings(keys: AllSettingItemKey[]) {
let hasChanged = false;
const appliedKeys = keys.filter((k) => this.isDirty(k));
const appliedKeys = keys.filter((k) => this.isDirty(k) && !this.isReconstructionRequiredSetting(k));
await this.saveConfigurationEncryptionIfDirty(appliedKeys);
for (const k of appliedKeys) {
if (this.isConfigurationEncryptionSetting(k)) continue;
@@ -193,11 +198,16 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
await Promise.all(handlers);
// }
keys.forEach((e) => {
if (this.isReconstructionRequiredSetting(e) && this.isDirty(e)) return;
if (this.isConfigurationEncryptionSetting(e) && this.isDirty(e)) return;
this.refreshSetting(e);
});
}
private isReconstructionRequiredSetting(key: AllSettingItemKey): boolean {
return key === "pathNormalizationVersion";
}
private isConfigurationEncryptionSetting(key: AllSettingItemKey): boolean {
return key === "configPassphraseStore" || key === "configPassphrase";
}
@@ -216,6 +226,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
appliedConfigurationEncryption.configPassphrase
);
if (appliedConfigurationEncryption.configPassphraseStore === "LOCALSTORAGE") {
this.localSettingsReadVersion++;
this.localConfigurationPassphrase = appliedConfigurationEncryption.configPassphrase;
}
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphraseStore");
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphrase");
}
@@ -225,8 +239,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
* @param keys setting keys for applying
*/
async saveAllDirtySettings() {
const changedKeys = (Object.keys(this.editingSettings ?? {}) as AllSettingItemKey[]).filter((e) =>
this.isDirty(e)
const changedKeys = (Object.keys(this.editingSettings ?? {}) as AllSettingItemKey[]).filter(
(e) => this.isDirty(e) && !this.isReconstructionRequiredSetting(e)
);
await this.saveSettings(changedKeys);
this.reloadAllSettings();
@@ -276,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() {
const ret = { ...OnDialogSettingsDefault };
ret.configPassphrase = compatGlobal.localStorage.getItem("ls-setting-passphrase") || "";
ret.configPassphrase = this.localConfigurationPassphrase;
ret.preset = "";
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
return ret;
@@ -299,6 +325,13 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
reloadAllSettings(skipUpdate: boolean = false) {
const previousSettings = this._editingSettings;
const previousSnapshot = this.initialSettings;
const pendingPathNormalizationVersion =
previousSettings && previousSnapshot && this.isDirty("pathNormalizationVersion")
? {
value: previousSettings.pathNormalizationVersion,
appliedValue: previousSnapshot.pathNormalizationVersion,
}
: undefined;
const pendingConfigurationEncryption =
previousSettings && previousSnapshot
? {
@@ -326,6 +359,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
if (pendingConfigurationEncryption?.configPassphrase) {
this._editingSettings.configPassphrase = pendingConfigurationEncryption.configPassphrase.value;
}
if (pendingPathNormalizationVersion) {
this._editingSettings.pathNormalizationVersion = pendingPathNormalizationVersion.value;
this.initialSettings.pathNormalizationVersion = pendingPathNormalizationVersion.appliedValue;
}
if (!skipUpdate) this.requestUpdate();
}
@@ -333,6 +370,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
* Reread each setting and request invalidate
*/
refreshSetting(key: AllSettingItemKey) {
if (this.isReconstructionRequiredSetting(key) && this.isDirty(key)) {
this.requestUpdate();
return;
}
const localSetting = this.reloadAllLocalSettings();
if (key in this.core.settings) {
if (key in localSetting) {
@@ -378,7 +419,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
this.plugin = plugin;
Setting.env = this;
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("showStatusOnEditor", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR));
@@ -521,7 +562,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
);
}
requestReload() {
async requestReload(): Promise<void> {
if (!(await this.readLocalSettings())) return;
const nativeTabIsShown =
this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown();
if (this.isShown || nativeTabIsShown) {
@@ -627,6 +669,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
"passphrase",
"useDynamicIterationCount",
"usePathObfuscation",
"pathNormalizationVersion",
"encrypt",
// "remoteType",
]);
@@ -637,6 +680,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
"passphrase",
"useDynamicIterationCount",
"usePathObfuscation",
"pathNormalizationVersion",
"encrypt",
]);
}
@@ -682,16 +726,23 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
override getControlValue(key: string): unknown {
const spec = this.getDeclarativeSettingSpec(key);
return this.editingSettings[spec.key];
const value = this.editingSettings[spec.key];
return spec.control.type === "numeric-dropdown" ? `${value}` : value;
}
override async setControlValue(key: string, value: unknown): Promise<void> {
const spec = this.getDeclarativeSettingSpec(key);
if (!isValidSettingSpecValue(spec, value)) {
const persistedValue =
spec.control.type === "numeric-dropdown" && typeof value === "string" ? Number(value) : value;
if (!isValidSettingSpecValue(spec, persistedValue)) {
throw new TypeError(`Invalid value for declarative setting ${key}`);
}
Reflect.set(this.editingSettings, spec.key, value);
await this.saveSettings([spec.key]);
Reflect.set(this.editingSettings, spec.key, persistedValue);
if (spec.holdValue) {
this.requestUpdate();
} else {
await this.saveSettings([spec.key]);
}
}
private createRebuildRequiredAction(): SettingDefinitionAction {
@@ -1059,6 +1110,15 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
};
rebuildDB = async (method: "localOnly" | "remoteOnly" | "rebuildBothByThisDevice" | "localOnlyWithChunks") => {
if (method === "remoteOnly" && this.isDirty("pathNormalizationVersion")) {
Logger(
$msg(
"CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1."
),
LOG_LEVEL_NOTICE
);
return;
}
if (this.editingSettings.encrypt && this.editingSettings.passphrase == "") {
Logger($msg("obsidianLiveSyncSettingTab.logEncryptionNoPassphrase"), LOG_LEVEL_NOTICE);
return;
@@ -1109,6 +1169,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
this.editingSettings.passphrase = "";
}
await this.saveAllDirtySettings();
await this.applyReconstructionRequiredSettings();
},
});
if (result.result === "scheduled") {
@@ -1118,6 +1179,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
if (result.result === "failed") {
return;
}
if (this.isDirty("pathNormalizationVersion")) {
return;
}
const applyWithoutInitialisation = $msg(
"Ui.SetupWizard.ApplySettingsInitialisation.ApplyWithoutInitialisation"
@@ -1136,6 +1200,23 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
}
}
private async applyReconstructionRequiredSettings(): Promise<void> {
const key = "pathNormalizationVersion";
if (!this.isDirty(key)) return;
const previousValue = this.core.settings.pathNormalizationVersion;
this.copySettingValue(this.core.settings, this.editingSettings, key);
try {
await this.services.setting.saveSettingData();
} catch (error) {
this.core.settings.pathNormalizationVersion = previousValue;
throw error;
}
this.copySettingValue(this.initialSettings, this.core.settings, key);
this.refreshSetting(key);
}
// The imperative renderer remains required by the declared Obsidian 1.7.2 minimum version.
override display(): void {
this.displayImperative();
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
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 { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { MemoryValueStore } from "@vrtmrz/value-store";
const settingsInitialisationMocks = vi.hoisted(() => ({
applySettingsWithInitialisationChoice: vi.fn(),
@@ -76,8 +76,6 @@ beforeEach(() => {
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset();
loggerMocks.Logger.mockClear();
vi.mocked($msg).mockClear();
vi.mocked(compatGlobal.localStorage.getItem).mockReset().mockReturnValue(null);
vi.mocked(compatGlobal.localStorage.setItem).mockReset();
});
describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
@@ -280,6 +278,7 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
},
services: {
setting: {
deviceValues: new MemoryValueStore(),
saveSettingData,
getDeviceAndVaultName: vi.fn(() => ""),
suspendAllSync,
@@ -316,6 +315,117 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
};
}
it("requires local and remote rebuilds when path identity changes", () => {
const { tab } = createSettingsTab();
const initialSettings = tab.initialSettings!;
initialSettings.handleFilenameCaseSensitive = false;
tab.editingSettings.handleFilenameCaseSensitive = false;
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
expect(tab.isNeedRebuildLocal()).toBe(true);
expect(tab.isNeedRebuildRemote()).toBe(true);
});
it("does not offer settings-only fallback when path identity changes", async () => {
const { tab, saveSettingData, confirmWithMessage } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockResolvedValueOnce({
result: "cancelled",
});
await tab.confirmRebuild();
expect(confirmWithMessage).not.toHaveBeenCalled();
expect(saveSettingData).not.toHaveBeenCalled();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(Reflect.get(initialSettings, "pathNormalizationVersion")).toBe(0);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
});
it.each([
["regular-expression Apply", "syncOnlyRegEx", "notes/.*"],
["preset Apply", "preset", "LIVESYNC"],
] as const)("keeps path identity pending during %s", async (_label, setting, value) => {
const { tab } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
Reflect.set(tab.editingSettings, setting, value);
if (setting === "preset") {
tab.addOnSaved("preset", async () => {
tab.editingSettings.liveSync = true;
await tab.saveAllDirtySettings();
});
}
await tab.saveAllDirtySettings();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
if (setting === "syncOnlyRegEx") {
expect(tab.core.settings.syncOnlyRegEx).toBe(value);
} else {
expect(tab.core.settings.liveSync).toBe(true);
}
});
it("excludes path identity from direct save operations", async () => {
const { tab, saveSettingData } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
await tab.saveSettings(["pathNormalizationVersion"]);
expect(saveSettingData).not.toHaveBeenCalled();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
});
it("excludes path identity from ordinary Apply operations", async () => {
const { tab, saveSettingData } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
await tab.applyAllSettings();
expect(saveSettingData).not.toHaveBeenCalled();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
});
it("retains a pending path identity edit and its applied baseline after reload", () => {
const { tab } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
tab.reloadAllSettings();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
expect(tab.isNeedRebuildLocal()).toBe(true);
expect(tab.isNeedRebuildRemote()).toBe(true);
});
it("keeps pending settings in the editing buffer when initialisation and the fallback are cancelled", async () => {
const { tab, saveSettingData, confirmWithMessage, closeSetting } = createSettingsTab();
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockResolvedValueOnce({
@@ -353,8 +463,11 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
expect(closeSetting).not.toHaveBeenCalled();
});
it("closes settings only after initialisation has been scheduled", async () => {
it("applies path identity only from a successful initialisation reservation callback", async () => {
const { tab, saveSettingData, confirmWithMessage, closeSetting } = createSettingsTab();
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(tab.initialSettings!, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockImplementationOnce(
async ({ applySettings }: { applySettings: () => Promise<void> }) => {
await applySettings();
@@ -364,9 +477,118 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
await tab.confirmRebuild();
expect(saveSettingData).toHaveBeenCalledOnce();
expect(saveSettingData).toHaveBeenCalledTimes(2);
expect(confirmWithMessage).not.toHaveBeenCalled();
expect(closeSetting).toHaveBeenCalledOnce();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(1);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(1);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(false);
});
it("rolls path identity back and keeps it pending when saving after reservation fails", async () => {
const { tab, saveSettingData, closeSetting } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
tab.editingSettings.handleFilenameCaseSensitive = false;
const failure = new Error("settings could not be saved");
saveSettingData.mockRejectedValueOnce(failure);
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockImplementationOnce(
async ({ applySettings }: { applySettings: () => Promise<void> }) => {
await applySettings();
return { result: "scheduled", mode: "rebuild" };
}
);
await expect(tab.confirmRebuild()).rejects.toBe(failure);
expect(saveSettingData).toHaveBeenCalledOnce();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
expect(closeSetting).not.toHaveBeenCalled();
});
it("retains pending path identity when fetch validation fails", async () => {
const { tab } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
vi.spyOn(tab, "checkWorkingPassphrase").mockResolvedValue(false);
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockImplementationOnce(
async ({ validateChoice }: { validateChoice: (mode: "fetch" | "rebuild") => Promise<boolean> }) => {
expect(await validateChoice("fetch")).toBe(false);
return { result: "failed", mode: "fetch" };
}
);
await tab.confirmRebuild();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
expect(tab.closeSetting).not.toHaveBeenCalled();
});
it("retains pending path identity when initialisation reservation fails", async () => {
const { tab, saveSettingData } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockResolvedValueOnce({
result: "failed",
mode: "rebuild",
});
await tab.confirmRebuild();
expect(saveSettingData).not.toHaveBeenCalled();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
expect(tab.closeSetting).not.toHaveBeenCalled();
});
it("refuses remote-only overwrite with pending path identity before side effects", async () => {
const { tab, saveSettingData, suspendAllSync, suspendExtraSync, performRebuildDB, closeSetting } =
createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
tab.editingSettings.encrypt = false;
const applyAllSettings = vi.spyOn(tab, "applyAllSettings");
const reloadAllSettings = vi.spyOn(tab, "reloadAllSettings");
await tab.rebuildDB("remoteOnly");
expect(applyAllSettings).not.toHaveBeenCalled();
expect(suspendAllSync).not.toHaveBeenCalled();
expect(suspendExtraSync).not.toHaveBeenCalled();
expect(reloadAllSettings).not.toHaveBeenCalled();
expect(saveSettingData).not.toHaveBeenCalled();
expect(performRebuildDB).not.toHaveBeenCalled();
expect(closeSetting).not.toHaveBeenCalled();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
expect(vi.mocked($msg)).toHaveBeenCalledWith(
"CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1."
);
expect(loggerMocks.Logger).toHaveBeenCalledWith(
expect.stringContaining(
"Rebuild both databases from a complete authoritative Vault and Fetch on the other devices."
),
LOG_LEVEL_NOTICE
);
});
it("does not offer the settings-only fallback after an initialisation failure", async () => {
@@ -442,6 +664,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
},
services: {
setting: {
deviceValues: new MemoryValueStore(),
saveSettingData,
getDeviceAndVaultName: vi.fn(() => ""),
},
@@ -469,6 +692,54 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
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", () => {
const { tab } = createSettingsTab();
@@ -489,6 +760,33 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
expect(saveSettings).toHaveBeenCalledWith(["hashCacheMaxCount"]);
});
it("keeps a path identity dropdown edit pending until the rebuild flow applies it", async () => {
const { tab, saveSettingData } = createSettingsTab();
const initialSettings = tab.initialSettings!;
const saveSettings = vi.spyOn(tab, "saveSettings");
vi.spyOn(tab, "requestUpdate").mockImplementation(() => undefined);
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
expect(tab.getControlValue("pathNormalizationVersion")).toBe("0");
await tab.setControlValue("pathNormalizationVersion", "1");
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.getControlValue("pathNormalizationVersion")).toBe("1");
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(initialSettings, "pathNormalizationVersion")).toBe(0);
expect(saveSettings).not.toHaveBeenCalled();
expect(tab.requestUpdate).toHaveBeenCalledOnce();
expect(saveSettingData).not.toHaveBeenCalled();
expect(tab.isNeedRebuildLocal()).toBe(true);
expect(tab.isNeedRebuildRemote()).toBe(true);
await expect(tab.setControlValue("pathNormalizationVersion", "0.5")).rejects.toThrow(
/Invalid value for declarative setting/u
);
});
it("rejects unregistered declarative control keys", async () => {
const { tab } = createSettingsTab();
@@ -529,7 +827,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
it("saves a custom configuration passphrase edit through the setting service", async () => {
const { tab } = createSettingsTab();
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 });
const store = "LOCALSTORAGE";
@@ -537,13 +835,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
tab.initialSettings!.configPassphraseStore = store;
tab.editingSettings.configPassphrase = "new 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"]);
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
expect(changeConfigurationEncryption).toHaveBeenCalledWith(store, "new custom passphrase");
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled();
expect(setValue).toHaveBeenCalledOnce();
expect(tab.initialSettings!.configPassphrase).toBe("new custom passphrase");
});
@@ -561,7 +859,6 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
tab.initialSettings!.configPassphraseStore = "LOCALSTORAGE";
tab.initialSettings!.configPassphrase = "previous passphrase";
tab.editingSettings.configPassphrase = "passphrase A";
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("passphrase A");
const apply = tab.saveSettings(["configPassphrase"]);
expect(changeConfigurationEncryption).toHaveBeenCalledWith("LOCALSTORAGE", "passphrase A");
@@ -590,13 +887,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
tab.editingSettings.configPassphraseStore = editedStore;
tab.initialSettings!.configPassphrase = "old 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);
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
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.initialSettings!.configPassphraseStore).toBe(savedStore);
expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase");
@@ -43,7 +43,7 @@ describe("paneAdvanced", () => {
};
paneAdvanced.call(host as never, {} as HTMLElement, { addPanel } as never);
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(9));
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(11));
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
"Memory cache",
@@ -55,6 +55,8 @@ describe("paneAdvanced", () => {
panelElements.get("Memory cache"),
panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"),
@@ -66,6 +68,8 @@ describe("paneAdvanced", () => {
"hashCacheMaxCount",
"chunkSplitterVersion",
"customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -10,12 +10,13 @@ import {
import { createBlob, escapeMarkdownValue, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { shouldBeIgnored } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { Menu, diff_match_patch, setIcon } from "@/deps.ts";
import { Menu, TFile, diff_match_patch, setIcon } from "@/deps.ts";
import { $msg } from "@/common/translation";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import {
EVENT_ANALYSE_DB_USAGE,
EVENT_INSPECT_DB_INTEGRITY,
EVENT_REQUEST_CHECK_REMOTE_SIZE,
EVENT_REQUEST_RUN_DOCTOR,
EVENT_REQUEST_RUN_FIX_INCOMPLETE,
@@ -61,10 +62,70 @@ import {
executeMetadataIdentityRepair,
MetadataIdentityRepairExecutions,
} from "@/serviceFeatures/metadataIdentityRepair.ts";
import { showDiagnosticFileLookup } from "@/serviceFeatures/diagnosticFileLookup.ts";
export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
// const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` });
// hatchWarn.addClass("op-warn-info");
void addPanel(paneEl, $msg("Setting.TroubleShooting")).then((paneEl) => {
void addPanel(paneEl, "Reporting Tools").then((paneEl) => {
new Setting(paneEl).setName($msg("Prepare the 'report' to create an issue")).addButton((button) =>
button
.setButtonText($msg("Copy Report to clipboard"))
.setCta()
.setDisabled(false)
.onClick(async () => {
await this.app.commands.executeCommandById("obsidian-livesync:dump-debug-info");
})
);
new Setting(paneEl)
.setName($msg("Copy database information for a file"))
.setDesc(
$msg(
"Copy revision, conflict, and local chunk availability information, including document and chunk identifiers but not file contents."
)
)
.addButton((button) =>
button.setButtonText($msg("Choose file")).onClick(async () => {
await chooseAndCopyFileDatabaseInfo(this.core);
})
);
new Setting(paneEl)
.setName($msg("Find a file from a report pseudonym"))
.setDesc(
$msg(
"Find matching paths in this Vault and local Metadata, then prepare a follow-up report using the same pseudonyms."
)
)
.addButton((button) =>
button.setButtonText($msg("Find file")).onClick(async () => {
await showDiagnosticFileLookup(this.core, async (path) => {
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) await this.app.workspace.getLeaf(false).openFile(file);
});
})
);
new Setting(paneEl)
.setName($msg("Inspect database integrity"))
.setDesc(
$msg("Read normal-file Metadata and current conflicts locally and in CouchDB, then compare content for suspect groups. Choose original names or pseudonyms before starting.")
)
.addButton((button) =>
button.setButtonText($msg("Inspect")).onClick(() => eventHub.emitEvent(EVENT_INSPECT_DB_INTEGRITY))
);
new Setting(paneEl)
.setName($msg("Analyse database usage"))
.setDesc(
$msg(
"Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like."
)
)
.addButton((button) =>
button.setButtonText($msg("Analyse")).onClick(() => {
eventHub.emitEvent(EVENT_ANALYSE_DB_USAGE);
})
);
});
void addPanel(paneEl, "Checks and Recovery").then((paneEl) => {
new Setting(paneEl)
.setName($msg("Setting.TroubleShooting.Doctor"))
.setDesc($msg("Setting.TroubleShooting.Doctor.Desc"))
@@ -91,40 +152,9 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
eventHub.emitEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE);
})
);
});
new Setting(paneEl).setName($msg("Prepare the 'report' to create an issue")).addButton((button) =>
button
.setButtonText($msg("Copy Report to clipboard"))
.setCta()
.setDisabled(false)
.onClick(async () => {
await this.app.commands.executeCommandById("obsidian-livesync:dump-debug-info");
})
);
new Setting(paneEl)
.setName($msg("Copy database information for a file"))
.setDesc(
$msg(
"Copy revision, conflict, and local chunk availability information, including document and chunk identifiers but not file contents."
)
)
.addButton((button) =>
button.setButtonText($msg("Choose file")).onClick(async () => {
await chooseAndCopyFileDatabaseInfo(this.core);
})
);
new Setting(paneEl)
.setName($msg("Analyse database usage"))
.setDesc(
$msg(
"Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like."
)
)
.addButton((button) =>
button.setButtonText($msg("Analyse")).onClick(() => {
eventHub.emitEvent(EVENT_ANALYSE_DB_USAGE);
})
);
void addPanel(paneEl, $msg("Setting.TroubleShooting")).then((paneEl) => {
new Setting(paneEl)
.setName($msg("Reset notification threshold and check the remote database usage"))
.setDesc($msg("Reset the remote storage size threshold and check the remote storage size again."))
@@ -7,6 +7,7 @@ import {
type ObsidianLiveSyncSettings,
LOG_LEVEL_VERBOSE,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
import { Menu, type ButtonComponent } from "@/deps.ts";
import { $msg } from "@/common/translation";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
@@ -101,6 +102,22 @@ export function paneRemoteConfig(
paneEl: HTMLElement,
{ addPanel }: PageFunctions
): void {
new Setting(paneEl)
.setName($msg("Joined devices"))
.setDesc($msg("Review device records for the active CouchDB or Object Storage connection."))
.addButton((button) =>
button.setButtonText($msg("Review devices")).onClick(async () => {
const result = await this.services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
if (result.status === "unavailable" || result.value === "unavailable") {
this.services.API.addLog(
$msg(
"Joined devices are available after database startup with an active CouchDB or Object Storage connection."
),
LOG_LEVEL_NOTICE
);
}
})
);
{
/* E2EE */
const E2EEInitialProps = {
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const runtime = vi.hoisted(() => ({
buttonClasses: [] as string[],
clickHandlers: [] as Array<() => Promise<void> | void>,
clickHandlers: [] as Array<{ setting: string; run: () => Promise<void> | void }>,
panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>,
settingClasses: [] as string[],
}));
@@ -32,7 +32,10 @@ vi.mock("./LiveSyncSetting.ts", () => ({
},
};
setName() {
settingName = "";
setName(name: string) {
this.settingName = name;
return this;
}
@@ -41,6 +44,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
}
addButton(callback: (button: unknown) => void) {
const setting = this.settingName;
const button = {
buttonEl: {
classList: {
@@ -53,7 +57,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
return this;
},
onClick(callback: () => Promise<void> | void) {
runtime.clickHandlers.push(callback);
runtime.clickHandlers.push({ setting, run: callback });
return this;
},
setButtonText() {
@@ -107,6 +111,7 @@ vi.mock("./remoteConfigBuffer.ts", () => ({
syncActivatedRemoteSettings: vi.fn(),
}));
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
import { paneRemoteConfig } from "./PaneRemoteConfig.ts";
function createPanelElement(): HTMLElement {
@@ -124,6 +129,18 @@ afterEach(() => {
});
describe("paneRemoteConfig", () => {
it("opens joined devices through the shared UI command", async () => {
const invoke = vi.fn(async () => ({ status: "handled", value: "closed" }));
const host = {
editingSettings: { remoteConfigurations: {} },
services: { UI: { commands: { invoke } } },
};
const addPanel = vi.fn(() => ({ then: vi.fn() }));
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await runtime.clickHandlers.find((handler) => handler.setting === "Joined devices")!.run();
expect(invoke).toHaveBeenCalledWith(OPEN_JOINED_DEVICES, undefined);
});
it("destroys the E2EE info panel when the settings page lifetime unloads", async () => {
const callbacks: Array<() => unknown> = [];
const lifetimeComponent = {
@@ -187,7 +204,7 @@ describe("paneRemoteConfig", () => {
}));
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await runtime.clickHandlers[0]();
await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
expect(setupManager.onlyE2EEConfiguration).toHaveBeenCalledOnce();
expect(host.editingSettings.encryptInternalMetadata).toBe(true);
@@ -233,7 +250,7 @@ describe("paneRemoteConfig", () => {
}));
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await runtime.clickHandlers[0]();
await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
expect(host.editingSettings.idDerivationVersion).toBe(1);
expect(host.editingSettings.idDerivationKey).toBe(nextIdKey);
@@ -13,7 +13,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
.setName($msg("Synchronising files"))
.setDesc(
$msg(
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files."
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns."
)
);
const syncFilesControl = mount(MultipleRegExpControl, {
@@ -32,9 +32,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
const nonSyncFilesSetting = new Setting(paneEl)
.setName($msg("Non-Synchronising files"))
.setDesc(
$msg("(RegExp) If this is set, any changes to local and remote files that match this will be skipped.")
);
.setDesc($msg("(RegExp) Skip changes to matching normal and hidden files, locally and remotely."));
const nonSyncFilesControl = mount(MultipleRegExpControl, {
target: nonSyncFilesSetting.controlEl,
@@ -171,20 +171,18 @@ export function paneSyncSettings(
}
});
void addPanel(
paneEl,
$msg("obsidianLiveSyncSettingTab.titleUpdateThinning"),
undefined,
visibleOnly(() => !this.isConfiguredAs("syncMode", "LIVESYNC"))
).then((paneEl) => {
new Setting(paneEl).autoWireToggle("batchSave");
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleUpdateThinning")).then((paneEl) => {
const inLiveSyncMode = () => this.isConfiguredAs("syncMode", "LIVESYNC");
const batchEnabled = () => this.isConfiguredAs(inLiveSyncMode() ? "batchSaveInLiveSync" : "batchSave", true);
new Setting(paneEl).autoWireToggle("batchSave", { onUpdate: visibleOnly(() => !inLiveSyncMode()) });
new Setting(paneEl).autoWireToggle("batchSaveInLiveSync", { onUpdate: visibleOnly(inLiveSyncMode) });
new Setting(paneEl).autoWireNumeric("batchSaveMinimumDelay", {
acceptZero: true,
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
onUpdate: visibleOnly(batchEnabled),
});
new Setting(paneEl).autoWireNumeric("batchSaveMaximumDelay", {
acceptZero: true,
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
onUpdate: visibleOnly(batchEnabled),
});
});
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
import type { OnUpdateFunc, PageFunctions } from "./SettingPane";
const controls = vi.hoisted(() => new Map<string, { onUpdate?: () => { visibility?: boolean } }>());
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", () => ({ Logger: vi.fn() }));
vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class {
autoWireDropDown(key: string, options = {}) {
controls.set(key, options);
return this;
}
autoWireToggle(key: string, options = {}) {
controls.set(key, options);
return this;
}
autoWireNumeric(key: string, options = {}) {
controls.set(key, options);
return this;
}
},
}));
import { paneSyncSettings } from "./PaneSyncSettings";
function setup(liveSync: boolean, batchSave: boolean, batchSaveInLiveSync: boolean) {
const callbacks = new Map<string, (value: string) => Promise<void>>();
const panels = new Map<string, OnUpdateFunc | undefined>();
const tab = {
editingSettings: { ...DEFAULT_SETTINGS, liveSync, batchSave, batchSaveInLiveSync },
services: { API: { isMobile: () => false }, control: { applySettings: vi.fn(async () => {}) } },
addOnSaved: (key: string, callback: (value: string) => Promise<void>) => callbacks.set(key, callback),
isConfiguredAs(key: string, value: unknown) {
if (key === "syncMode") {
return (
(this.editingSettings.liveSync
? "LIVESYNC"
: this.editingSettings.periodicReplication
? "PERIODIC"
: "ONEVENTS") === value
);
}
return this.editingSettings[key as keyof typeof this.editingSettings] === value;
},
saveSettings: vi.fn(async () => {}),
requestCatalogueRefresh: vi.fn(),
};
const functions = {
addPanel: (_element: HTMLElement, title: string, _callback: unknown, onUpdate?: OnUpdateFunc) => ({
then(callback: (element: HTMLElement) => void) {
if (!title.endsWith("titleSynchronizationMethod") && !title.endsWith("titleUpdateThinning")) return;
panels.set(title, onUpdate);
callback({} as HTMLElement);
},
}),
} as unknown as PageFunctions;
paneSyncSettings.call(tab as unknown as ObsidianLiveSyncSettingTab, {} as HTMLElement, functions);
return { tab, callbacks, panels };
}
function visible(key: string): boolean {
const control = controls.get(key);
if (!control) throw new Error(`Missing setting control: ${key}`);
return control.onUpdate?.().visibility !== false;
}
beforeEach(() => controls.clear());
describe("mode-specific database batching settings", () => {
it.each([
{ liveSync: true, batchSave: true, batchSaveInLiveSync: false, delays: false },
{ liveSync: true, batchSave: false, batchSaveInLiveSync: true, delays: true },
{ liveSync: false, batchSave: true, batchSaveInLiveSync: false, delays: true },
{ liveSync: false, batchSave: false, batchSaveInLiveSync: true, delays: false },
])("shows the current mode choice and its shared delays: %j", ({ delays, ...settings }) => {
const { panels } = setup(settings.liveSync, settings.batchSave, settings.batchSaveInLiveSync);
expect(panels.get("obsidianLiveSyncSettingTab.titleUpdateThinning")?.().visibility).not.toBe(false);
expect(visible("batchSave")).toBe(!settings.liveSync);
expect(visible("batchSaveInLiveSync")).toBe(settings.liveSync);
expect(visible("batchSaveMinimumDelay")).toBe(delays);
expect(visible("batchSaveMaximumDelay")).toBe(delays);
});
it.each([
[true, false],
[false, true],
])("retains both batching choices across mode changes: %j", async (batchSave, batchSaveInLiveSync) => {
const { tab, callbacks } = setup(false, batchSave, batchSaveInLiveSync);
const switchMode = callbacks.get("syncMode")!;
for (const mode of ["LIVESYNC", "PERIODIC", "ONEVENTS", "LIVESYNC"]) {
await switchMode(mode);
expect(tab.editingSettings).toMatchObject({ batchSave, batchSaveInLiveSync });
}
expect(tab.services.control.applySettings).toHaveBeenCalledTimes(4);
});
});
@@ -11,6 +11,7 @@ export type PersistedSettingKey = PersistedBooleanSettingKey | PersistedStringSe
type SettingSpecBase<K extends PersistedSettingKey, C> = {
key: K;
control: C;
holdValue?: boolean;
visible?: () => boolean;
disabled?: () => boolean;
aliases?: string[];
@@ -34,6 +35,14 @@ type NumberSettingSpec = SettingSpecBase<
}
>;
type NumericDropdownSettingSpec = SettingSpecBase<
PersistedNumericSettingKey,
{
type: "numeric-dropdown";
options: () => Record<string, string>;
}
>;
type DropdownSettingSpec = SettingSpecBase<
PersistedStringSettingKey,
{
@@ -42,7 +51,7 @@ type DropdownSettingSpec = SettingSpecBase<
}
>;
export type SettingSpec = ToggleSettingSpec | NumberSettingSpec | DropdownSettingSpec;
export type SettingSpec = ToggleSettingSpec | NumberSettingSpec | NumericDropdownSettingSpec | DropdownSettingSpec;
/** An existing Advanced-page panel and the standard controls rendered within it. */
export type SettingSpecGroup = {
@@ -54,7 +63,7 @@ export type SettingSpecMessages = {
valueShouldBeInRange: (range: { min?: number; max?: number }) => string;
};
type LegacySettingOptions = Pick<AutoWireOption, "onUpdate">;
type LegacySettingOptions = Pick<AutoWireOption, "holdValue" | "onUpdate">;
export type LegacySettingBinding =
| {
@@ -71,6 +80,11 @@ export type LegacySettingBinding =
acceptZero?: boolean;
};
}
| {
type: "numeric-dropdown";
key: PersistedNumericSettingKey;
options: LegacySettingOptions & { options: Record<string, string> };
}
| {
type: "dropdown";
key: PersistedStringSettingKey;
@@ -83,6 +97,10 @@ export interface LegacySettingSpecRenderer {
key: AllNumericItemKey,
options: AutoWireOption & { clampMin?: number; clampMax?: number; acceptZero?: boolean }
): unknown;
autoWireNumericDropDown(
key: AllNumericItemKey,
options: AutoWireOption & { options: Record<string, string> }
): unknown;
autoWireDropDown(key: AllStringItemKey, options: AutoWireOption & { options: Record<string, string> }): unknown;
}
@@ -94,15 +112,21 @@ function isNumberSettingSpec(spec: SettingSpec): spec is NumberSettingSpec {
return spec.control.type === "number";
}
function isNumericDropdownSettingSpec(spec: SettingSpec): spec is NumericDropdownSettingSpec {
return spec.control.type === "numeric-dropdown";
}
function toLegacyUpdateOptions(spec: SettingSpec): LegacySettingOptions {
if (!spec.visible && !spec.disabled) {
return {};
}
return {
onUpdate: (): OnUpdateResult => ({
...(spec.visible ? { visibility: spec.visible() } : {}),
...(spec.disabled ? { disabled: spec.disabled() } : {}),
}),
...(spec.holdValue ? { holdValue: spec.holdValue } : {}),
...(!spec.visible && !spec.disabled
? {}
: {
onUpdate: (): OnUpdateResult => ({
...(spec.visible ? { visibility: spec.visible() } : {}),
...(spec.disabled ? { disabled: spec.disabled() } : {}),
}),
}),
};
}
@@ -131,6 +155,16 @@ export function toLegacySettingBinding(spec: SettingSpec): LegacySettingBinding
},
};
}
if (isNumericDropdownSettingSpec(spec)) {
return {
type: "numeric-dropdown",
key: spec.key,
options: {
...updateOptions,
options: spec.control.options(),
},
};
}
return {
type: "dropdown",
key: spec.key,
@@ -151,6 +185,9 @@ export function renderLegacySettingSpec(renderer: LegacySettingSpecRenderer, spe
case "number":
renderer.autoWireNumeric(binding.key, binding.options);
return;
case "numeric-dropdown":
renderer.autoWireNumericDropDown(binding.key, binding.options);
return;
case "dropdown":
renderer.autoWireDropDown(binding.key, binding.options);
}
@@ -176,6 +213,12 @@ export function isValidSettingSpecValue(spec: SettingSpec, value: unknown): bool
return typeof value === "boolean";
case "number":
return typeof value === "number" && !numberIsOutOfRange(value, spec.control);
case "numeric-dropdown":
return (
typeof value === "number" &&
Number.isInteger(value) &&
Object.prototype.hasOwnProperty.call(spec.control.options(), `${value}`)
);
case "dropdown":
return typeof value === "string" && Object.prototype.hasOwnProperty.call(spec.control.options(), value);
}
@@ -229,6 +272,14 @@ export function toObsidianSettingDefinition(
};
break;
}
case "numeric-dropdown":
control = {
type: "dropdown",
key: spec.key,
options: spec.control.options(),
...(spec.disabled ? { disabled: spec.disabled } : {}),
};
break;
case "dropdown":
control = {
type: "dropdown",
@@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it, vi } from "vitest";
import { statusDisplay, type ConfigurationItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { getConfig, type OnDialogSettings } from "./settingConstants.ts";
import {
isValidSettingSpecValue,
renderLegacySettingSpec,
toLegacySettingBinding,
toObsidianSettingDefinition,
@@ -16,7 +17,7 @@ const rangeMessages = {
};
describe("Advanced setting specifications", () => {
it("lists only the nine controls currently exposed by the Advanced page", () => {
it("lists only the eleven controls currently exposed by the Advanced page", () => {
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => true });
expect(groups.map(({ heading }) => heading)).toEqual([
@@ -29,6 +30,8 @@ describe("Advanced setting specifications", () => {
"hashCacheMaxCount",
"chunkSplitterVersion",
"customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -38,12 +41,24 @@ describe("Advanced setting specifications", () => {
]);
});
it("keeps CouchDB visibility on the four remote chunk controls", () => {
it("exposes strict content checking as an ungated Advanced toggle", () => {
const spec = createAdvancedSettingSpecGroups({ isCouchDB: () => false })
.find(({ heading }) => heading === "Local Database Tweak")
?.items.find(({ key }) => key === "strictContentCheckOnStartup");
if (!spec) throw new Error("Strict content checking is not exposed on the Advanced page");
expect(spec.control).toEqual({ type: "toggle" });
expect(spec.visible).toBeUndefined();
expect(spec.disabled).toBeUndefined();
});
it("keeps path identity and the four remote chunk controls CouchDB-only", () => {
let couchDB = false;
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB });
const conditional = groups.flatMap(({ items }) => items).filter(({ visible }) => visible !== undefined);
expect(conditional.map(({ key }) => key)).toEqual([
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -55,6 +70,44 @@ describe("Advanced setting specifications", () => {
expect(conditional.every(({ visible }) => visible?.() === true)).toBe(true);
});
it("exposes CouchDB path identity as a two-choice numeric dropdown", () => {
let couchDB = false;
const spec = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB })
.flatMap(({ items }) => items)
.find(({ key }) => key === "pathNormalizationVersion");
if (!spec) throw new Error("Path identity mode is not exposed on the Advanced page");
if (spec.control.type !== "numeric-dropdown") throw new Error("Expected a numeric dropdown");
expect(spec.control).toMatchObject({ type: "numeric-dropdown", options: expect.any(Function) });
expect(spec.visible?.()).toBe(false);
couchDB = true;
expect(spec.visible?.()).toBe(true);
expect(spec.control.options()).toEqual({
"0": "Legacy path identity",
"1": "Unicode NFC path identity",
});
expect(isValidSettingSpecValue(spec, 0)).toBe(true);
expect(isValidSettingSpecValue(spec, 1)).toBe(true);
expect(isValidSettingSpecValue(spec, 0.5)).toBe(false);
expect(isValidSettingSpecValue(spec, 2)).toBe(false);
const definition = toObsidianSettingDefinition(spec, { name: "Path identity mode" }, rangeMessages);
expect(definition.control).toEqual({
type: "dropdown",
key: "pathNormalizationVersion",
options: { "0": "Legacy path identity", "1": "Unicode NFC path identity" },
});
expect(toLegacySettingBinding(spec)).toEqual({
type: "numeric-dropdown",
key: "pathNormalizationVersion",
options: {
holdValue: true,
onUpdate: expect.any(Function),
options: { "0": "Legacy path identity", "1": "Unicode NFC path identity" },
},
});
});
it("excludes transient settings-dialogue keys from the persisted key type", () => {
type OnDialogOverlap = Extract<keyof OnDialogSettings, PersistedSettingKey>;
expectTypeOf<OnDialogOverlap>().toEqualTypeOf<never>();
@@ -225,6 +278,7 @@ describe("SettingSpec conversion", () => {
const renderer = {
autoWireToggle: vi.fn(),
autoWireNumeric: vi.fn(),
autoWireNumericDropDown: vi.fn(),
autoWireDropDown: vi.fn(),
};
const visible = vi.fn(() => false);
@@ -241,6 +295,7 @@ describe("SettingSpec conversion", () => {
expect(options.onUpdate()).toEqual({ visibility: false });
expect(visible).toHaveBeenCalledOnce();
expect(renderer.autoWireNumeric).not.toHaveBeenCalled();
expect(renderer.autoWireNumericDropDown).not.toHaveBeenCalled();
expect(renderer.autoWireDropDown).not.toHaveBeenCalled();
});
});
@@ -86,6 +86,8 @@ describe("settings page catalogue", () => {
"hashCacheMaxCount",
"chunkSplitterVersion",
"customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
+19 -7
View File
@@ -1,3 +1,4 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_SETTINGS,
@@ -37,11 +38,6 @@ import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/
import { SetupManager, UserMode } from "./SetupManager";
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> {
return Promise.resolve();
}
@@ -65,6 +61,7 @@ function createLegacyRemoteSetting(): ObsidianLiveSyncSettings {
function createSetupManager() {
const setting = new TestSettingService(new ServiceContext(), {
deviceValues: new MemoryValueStore(),
APIService: {
getSystemVaultName: vi.fn(() => "vault"),
getAppID: vi.fn(() => "app"),
@@ -172,10 +169,12 @@ describe("SetupManager", () => {
const { manager, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually");
const configureManually = vi.spyOn(manager, "onConfigureManually").mockResolvedValue(true);
const newVaultSettings = createNewVaultSettings();
await manager.onOnboard(UserMode.NewUser);
expect(configureManually).toHaveBeenCalledWith(createNewVaultSettings(), UserMode.NewUser);
expect(newVaultSettings.customChunkSize).toBe(0);
expect(configureManually).toHaveBeenCalledWith(newVaultSettings, UserMode.NewUser);
});
it("compatibility: normalises imported flat remote settings from a Setup URI before applying", async () => {
@@ -487,8 +486,15 @@ describe("SetupManager", () => {
expect(applySettings).not.toHaveBeenCalled();
});
it("preserves modern profiles, display names, and the active selection from a Setup URI", async () => {
it.each([
{ importedSize: 0, expectedSize: 0 },
{ importedSize: undefined, expectedSize: 60 },
])("preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize", async ({ importedSize, expectedSize }) => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
customChunkSize: 60,
};
const imported = {
...DEFAULT_SETTINGS,
remoteConfigurations: {
@@ -507,6 +513,8 @@ describe("SetupManager", () => {
},
activeConfigurationId: "archive",
} as ObsidianLiveSyncSettings;
if (importedSize === undefined) delete (imported as Partial<ObsidianLiveSyncSettings>).customChunkSize;
else imported.customChunkSize = importedSize;
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce(imported)
.mockResolvedValueOnce("compatible-existing-user");
@@ -514,6 +522,7 @@ describe("SetupManager", () => {
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings");
const current = setting.currentSettings();
expect(current.customChunkSize).toBe(expectedSize);
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
expect(current.activeConfigurationId).toBe("archive");
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
@@ -523,6 +532,7 @@ describe("SetupManager", () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
customChunkSize: 60,
isConfigured: true,
remoteConfigurations: {
existing: {
@@ -554,6 +564,7 @@ describe("SetupManager", () => {
await manager.onCouchDBManualSetup(UserMode.ExistingUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.customChunkSize).toBe(60);
expect(current.remoteConfigurations.existing).toBeDefined();
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
expect(current.activeConfigurationId).not.toBe("existing");
@@ -662,6 +673,7 @@ describe("SetupManager", () => {
await manager.onP2PManualSetup(UserMode.NewUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.customChunkSize).toBe(0);
expect(Object.keys(current.remoteConfigurations)).toHaveLength(1);
expect(current.activeConfigurationId).not.toBe("");
expect(current.P2P_ActiveRemoteConfigurationId).toBe(current.activeConfigurationId);
@@ -52,26 +52,48 @@
const context = getDialogContext();
let error = $state("");
let connectionPathResetNotice = $state(false);
let peerNameReady = $state(false);
let mounted = false;
const hasValidTurnServer = $derived(hasP2PTurnConfiguration(syncSetting));
type Props = GuestDialogProps<SetupRemoteP2PResultType, SetupRemoteP2PInitialData>;
const { setResult, getInitialData }: Props = $props();
let connectionProbe: SetupRemoteP2PInitialData["connectionProbe"] | undefined;
onMount(() => {
mounted = true;
const initialData = getInitialData?.();
connectionProbe = initialData?.connectionProbe;
const initialSettings = initialData?.settings;
if (initialSettings) {
syncSetting = pickP2PSyncSettings(initialSettings);
}
const initialPeerName = (initialSettings?.P2P_DevicePeerName ?? "").trim();
const savedPeerName = initialSettings?.P2P_DevicePeerName;
const initialPeerName = typeof savedPeerName === "string" ? savedPeerName.trim() : "";
if (initialPeerName !== "") {
return;
}
const cachedPeerName = context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME);
if (cachedPeerName) {
syncSetting.P2P_DevicePeerName = cachedPeerName as string;
peerNameReady = true;
return () => {
mounted = false;
};
}
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() {
const connSetting: P2PSyncSetting = {
@@ -193,6 +215,7 @@
}
async function checkAndCommit() {
if (!peerNameReady) return;
error = "";
try {
error = (await checkConnection()) || "";
@@ -207,6 +230,7 @@
}
}
function commit() {
if (!peerNameReady) return;
error = validateManagedTurnSettings(syncSetting) ?? "";
if (error) return;
const setting = pickP2PSyncSettings(generateSetting());
@@ -217,6 +241,7 @@
}
const canProceed = $derived.by(() => {
return (
peerNameReady &&
syncSetting.P2P_relays.trim() !== "" &&
syncSetting.P2P_roomID.trim() !== "" &&
syncSetting.P2P_passphrase.trim() !== "" &&
@@ -291,6 +316,7 @@
autocorrect="off"
autocapitalize="off"
spellcheck="false"
disabled={!peerNameReady}
bind:value={syncSetting.P2P_DevicePeerName}
/>
</InputRow>
@@ -385,7 +411,11 @@
disabled={!canProceed}
commit={() => checkAndCommit()}
/>
<Decision title={translateMessage("Continue anyway")} commit={() => commit()} />
<Decision
title={translateMessage("Continue anyway")}
disabled={!peerNameReady}
commit={() => commit()}
/>
<Decision title={translateMessage("Cancel")} commit={() => cancel()} />
</UserDecisions>
{/if}
+47 -4
View File
@@ -10,6 +10,15 @@ export type DisposableReactiveValue<T> = ReactiveValue<T> & {
dispose(): void;
};
export type PaddedCounterLabel = DisposableReactiveValue<string> & {
readonly count: ReactiveValue<number | undefined>;
};
export type ChunkFetchCounterLabel = DisposableReactiveValue<string> & {
readonly initialCount: ReactiveValue<number | undefined>;
readonly retryingCount: ReactiveValue<number | undefined>;
};
function asDisposableReactiveValue<T>(value: ReactiveSource<T>, dispose: () => void): DisposableReactiveValue<T> {
return {
get value() {
@@ -92,9 +101,10 @@ export function createPaddedCounterLabel(
source: ReactiveValue<number>,
mark: string,
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS
): DisposableReactiveValue<string> {
): PaddedCounterLabel {
const linger = Math.max(0, inactiveLingerMs);
const formatted = reactiveSource("");
const displayedCount = reactiveSource<number | undefined>(undefined);
let maximumLength = 1;
let clearTimer: number | undefined;
let disposed = false;
@@ -114,22 +124,39 @@ export function createPaddedCounterLabel(
cancelClear();
const count = source.value;
formatted.value = format(count);
displayedCount.value = count;
if (count !== 0) return;
clearTimer = compatGlobal.setTimeout(() => {
clearTimer = undefined;
if (disposed) return;
formatted.value = "";
displayedCount.value = undefined;
maximumLength = 1;
}, linger);
};
source.onChanged(update);
return asDisposableReactiveValue(formatted, () => {
const display = asDisposableReactiveValue(formatted, () => {
if (disposed) return;
disposed = true;
cancelClear();
source.offChanged(update);
});
return {
get value() {
return display.value;
},
onChanged(handler) {
display.onChanged(handler);
},
offChanged(handler) {
display.offChanged(handler);
},
count: displayedCount,
dispose() {
display.dispose();
},
};
}
/**
@@ -138,7 +165,7 @@ export function createPaddedCounterLabel(
*/
export function createChunkFetchCounterLabel(
source: ReactiveValue<{ initial: number; retrying: number }>
): DisposableReactiveValue<string> {
): ChunkFetchCounterLabel {
const initialCount = reactiveSource(0);
const retryingCount = reactiveSource(0);
const initialLabel = createPaddedCounterLabel(initialCount, "🛄");
@@ -168,7 +195,7 @@ export function createChunkFetchCounterLabel(
source.onChanged(updateCounts);
updateCounts();
return asDisposableReactiveValue(formatted, () => {
const display = asDisposableReactiveValue(formatted, () => {
if (disposed) return;
disposed = true;
source.offChanged(updateCounts);
@@ -177,4 +204,20 @@ export function createChunkFetchCounterLabel(
initialLabel.dispose();
retryingLabel.dispose();
});
return {
get value() {
return display.value;
},
onChanged(handler) {
display.onChanged(handler);
},
offChanged(handler) {
display.offChanged(handler);
},
initialCount: initialLabel.count,
retryingCount: retryingLabel.count,
dispose() {
display.dispose();
},
};
}
@@ -111,6 +111,24 @@ describe("createPaddedCounterLabel", () => {
display.dispose();
});
it("exposes the numeric value while retaining zero through the existing linger", () => {
const source = reactiveSource(0);
const display = createPaddedCounterLabel(source, "📥");
expect(display.count.value).toBeUndefined();
source.value = 9;
expect(display.count.value).toBe(9);
source.value = 0;
expect(display.count.value).toBe(0);
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1);
expect(display.count.value).toBe(0);
vi.advanceTimersByTime(1);
expect(display.count.value).toBeUndefined();
display.dispose();
});
it("cancels the pending clear when counter activity resumes", () => {
const source = reactiveSource(0);
const display = createPaddedCounterLabel(source, "📄");
+152
View File
@@ -0,0 +1,152 @@
const INACTIVE_CLASS = "livesync-stat-inactive";
export type StatusBarPart = {
key: string;
text: string;
value?: number;
suffix?: string;
title?: string;
};
export type StatusBarRenderer = {
render(parts: readonly StatusBarPart[]): void;
dispose(): void;
};
type RenderedItem = {
element: HTMLSpanElement;
prefix: Text;
value: HTMLSpanElement;
valueText: Text;
suffix: Text;
widestValueLength: number;
};
export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer {
const items = new Map<string, RenderedItem>();
let disposed = false;
const setText = (node: Text, text: string) => {
if (node.data !== text) node.data = text;
};
const setHidden = (element: HTMLElement, hidden: boolean) => {
if (element.hidden !== hidden) element.hidden = hidden;
};
const setInactive = (element: HTMLElement, inactive: boolean) => {
if (element.classList.contains(INACTIVE_CLASS) === inactive) return;
element.classList.toggle(INACTIVE_CLASS, inactive);
if (inactive) element.setAttribute("aria-hidden", "true");
else element.removeAttribute("aria-hidden");
};
const setWidth = (element: HTMLElement, width: string) => {
if (element.style.getPropertyValue("--livesync-stat-width") !== width) {
element.setCssProps({ "--livesync-stat-width": width });
}
};
const createItem = (key: string): RenderedItem => {
const document = parent.ownerDocument;
const element = parent.createSpan();
element.className = "livesync-stat-item livesync-stat-" + key;
const prefix = document.createTextNode("");
const value = element.createSpan();
value.className = "livesync-stat-value";
const valueText = document.createTextNode("");
value.appendChild(valueText);
value.hidden = true;
const suffix = document.createTextNode("");
element.appendChild(prefix);
element.appendChild(value);
element.appendChild(suffix);
const item = { element, prefix, value, valueText, suffix, widestValueLength: 0 };
items.set(key, item);
return item;
};
const updateItem = (item: RenderedItem, part: StatusBarPart) => {
setText(item.prefix, part.text);
const title = part.title ?? "";
if (item.element.title !== title) item.element.title = title;
if (part.value === undefined) {
setText(item.valueText, "");
setText(item.suffix, "");
setHidden(item.value, true);
setWidth(item.value, "");
item.widestValueLength = 0;
return;
}
const formattedValue = String(part.value);
setText(item.valueText, formattedValue);
setText(item.suffix, part.suffix ?? "");
setHidden(item.value, false);
if (formattedValue.length > item.widestValueLength) {
item.widestValueLength = formattedValue.length;
const width = item.widestValueLength + "ch";
setWidth(item.value, width);
}
};
const reorderIfNeeded = (visibleItems: readonly RenderedItem[]) => {
const desiredOrder = visibleItems.map((item) => item.element);
const visibleElements = new Set(desiredOrder);
const currentOrder = Array.from(parent.childNodes).filter((node): node is HTMLSpanElement =>
visibleElements.has(node as HTMLSpanElement)
);
if (
currentOrder.length === desiredOrder.length &&
currentOrder.every((node, index) => node === desiredOrder[index])
)
return;
let reference: ChildNode | null = currentOrder[0] ?? null;
for (const element of desiredOrder) {
if (element !== reference) parent.insertBefore(element, reference);
const childNodes = Array.from(parent.childNodes);
const elementIndex = childNodes.indexOf(element);
reference =
childNodes.slice(elementIndex + 1).find((node) => visibleElements.has(node as HTMLSpanElement)) ?? null;
}
};
return {
render(parts) {
if (disposed) return;
const visibleItems: RenderedItem[] = [];
const visibleKeys = new Set<string>();
for (const part of parts) {
let item = items.get(part.key);
if (!item) item = createItem(part.key);
if (item.element.classList.contains(INACTIVE_CLASS)) item.widestValueLength = 0;
updateItem(item, part);
setInactive(item.element, false);
visibleItems.push(item);
visibleKeys.add(part.key);
}
for (const [key, item] of items) {
if (visibleKeys.has(key)) continue;
setInactive(item.element, true);
}
reorderIfNeeded(visibleItems);
},
dispose() {
if (disposed) return;
disposed = true;
for (const item of items.values()) {
if (item.element.parentNode === parent) parent.removeChild(item.element);
}
items.clear();
},
};
}
@@ -0,0 +1,364 @@
import { describe, expect, it } from "vitest";
import { createStatusBarRenderer, type StatusBarPart } from "./StatusBarRenderer.ts";
type TestNode = TestElement | TestTextNode;
class TestDocument {
mutations = 0;
createElement(tagName: string): TestElement {
return new TestElement(this, tagName);
}
createTextNode(data: string): TestTextNode {
return new TestTextNode(this, data);
}
}
class TestTextNode {
readonly nodeType = 3;
parentNode: TestElement | undefined;
private currentData: string;
constructor(
private readonly document: TestDocument,
data: string
) {
this.currentData = data;
}
get data(): string {
return this.currentData;
}
set data(value: string) {
this.document.mutations++;
this.currentData = value;
}
get textContent(): string {
return this.currentData;
}
}
class TestStyle {
private properties = new Map<string, string>();
constructor(private readonly document: TestDocument) {}
getPropertyValue(name: string): string {
return this.properties.get(name) ?? "";
}
setProperty(name: string, value: string) {
this.document.mutations++;
this.properties.set(name, value);
}
}
class TestElement {
readonly nodeType = 1;
readonly ownerDocument: Document;
readonly childNodes: TestNode[] = [];
parentNode: TestElement | undefined;
readonly style: TestStyle;
private currentClassName = "";
private currentHidden = false;
private currentTitle = "";
private attributes = new Map<string, string>();
readonly classList = {
contains: (token: string): boolean => this.currentClassName.split(" ").includes(token),
toggle: (token: string, force: boolean): boolean => {
const tokens = this.currentClassName.split(" ").filter(Boolean);
const present = tokens.includes(token);
if (present !== force) {
this.className = force
? [...tokens, token].join(" ")
: tokens.filter((item) => item !== token).join(" ");
}
return force;
},
};
getAttribute(name: string): string | null {
return this.attributes.get(name) ?? null;
}
setAttribute(name: string, value: string) {
this.document.mutations++;
this.attributes.set(name, value);
}
removeAttribute(name: string) {
if (this.attributes.delete(name)) this.document.mutations++;
}
constructor(
private readonly document: TestDocument,
readonly tagName: string
) {
this.ownerDocument = document as unknown as Document;
this.style = new TestStyle(document);
}
get className(): string {
return this.currentClassName;
}
set className(value: string) {
this.document.mutations++;
this.currentClassName = value;
}
get hidden(): boolean {
return this.currentHidden;
}
set hidden(value: boolean) {
this.document.mutations++;
this.currentHidden = value;
}
get title(): string {
return this.currentTitle;
}
set title(value: string) {
this.document.mutations++;
this.currentTitle = value;
}
get children(): TestElement[] {
return this.childNodes.filter((node): node is TestElement => node instanceof TestElement);
}
get textContent(): string {
return this.childNodes.map((node) => node.textContent ?? "").join("");
}
createSpan(): TestElement {
return this.appendChild(this.document.createElement("span"));
}
setCssProps(properties: Record<string, string>) {
for (const [name, value] of Object.entries(properties)) this.style.setProperty(name, value);
}
appendChild<T extends TestNode>(node: T): T {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
this.childNodes.push(node);
node.parentNode = this;
this.document.mutations++;
return node;
}
insertBefore<T extends TestNode>(node: T, reference: TestNode | null): T {
if (node === reference) return node;
if (node.parentNode) {
node.parentNode.removeChild(node);
}
const index = reference ? this.childNodes.indexOf(reference) : -1;
if (index < 0) {
this.childNodes.push(node);
} else {
this.childNodes.splice(index, 0, node);
}
node.parentNode = this;
this.document.mutations++;
return node;
}
removeChild<T extends TestNode>(node: T): T {
const index = this.childNodes.indexOf(node);
if (index < 0) throw new Error("Node is not a child");
this.childNodes.splice(index, 1);
node.parentNode = undefined;
this.document.mutations++;
return node;
}
}
function createFixture() {
const document = new TestDocument();
const root = new TestElement(document, "div");
return { document, root, parent: root as unknown as HTMLElement };
}
function findItem(root: TestElement, key: string): TestElement {
const item = root.children.find((child) => child.className.split(" ").includes("livesync-stat-" + key));
if (!item) throw new Error("Missing item: " + key);
return item;
}
function findValue(item: TestElement): TestElement {
const value = item.children.find((child) => child.className.split(" ").includes("livesync-stat-value"));
if (!value) throw new Error("Missing numeric value");
return value;
}
function visibleItems(root: TestElement): TestElement[] {
return root.children.filter(
(child) => !child.classList.contains("livesync-stat-inactive") && child.classList.contains("livesync-stat-item")
);
}
describe("createStatusBarRenderer", () => {
it("renders complete text safely and gives each item an independent class hook", () => {
const { root, parent } = createFixture();
const renderer = createStatusBarRenderer(parent);
renderer.render([
{ key: "sync", text: "<sync>" },
{ key: "queue", text: " (queued: ", value: 2, suffix: ")" },
]);
const sync = findItem(root, "sync");
const queue = findItem(root, "queue");
expect(sync.className.split(" ")).toEqual(["livesync-stat-item", "livesync-stat-sync"]);
expect(sync.textContent).toBe("<sync>");
expect(queue.className.split(" ")).toContain("livesync-stat-queue");
expect(queue.textContent).toBe(" (queued: 2)");
expect(findValue(queue).className).toBe("livesync-stat-value");
renderer.render([{ key: "queue", text: " (queued: ", value: 3, suffix: ")" }]);
expect(sync.classList.contains("livesync-stat-inactive")).toBe(true);
expect(sync.hidden).toBe(false);
expect(sync.getAttribute("aria-hidden")).toBe("true");
expect(queue.classList.contains("livesync-stat-inactive")).toBe(false);
expect(queue.textContent).toBe(" (queued: 3)");
renderer.render([{ key: "queue", text: "Queue idle" }]);
expect(queue.textContent).toBe("Queue idle");
expect(findValue(queue).hidden).toBe(true);
renderer.dispose();
});
it("does not mutate the DOM when the same parts are rendered again", () => {
const { document, parent } = createFixture();
const renderer = createStatusBarRenderer(parent);
const parts: StatusBarPart[] = [
{ key: "pending", text: " pending ", value: 7, suffix: " files", title: "Pending" },
];
renderer.render(parts);
const mutations = document.mutations;
renderer.render(parts);
expect(document.mutations).toBe(mutations);
renderer.dispose();
});
it("updates values while retaining the widest visible numeric width", () => {
const { root, parent } = createFixture();
const renderer = createStatusBarRenderer(parent);
renderer.render([{ key: "count", text: "(", value: 9, suffix: ")" }]);
const item = findItem(root, "count");
const value = findValue(item);
expect(item.textContent).toBe("(9)");
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch");
renderer.render([{ key: "count", text: "(", value: 123, suffix: ")" }]);
expect(value.textContent).toBe("123");
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("3ch");
renderer.render([{ key: "count", text: "(", value: 4, suffix: ")" }]);
expect(value.textContent).toBe("4");
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("3ch");
renderer.dispose();
});
it("retains omitted content and width, then resets width when the same item returns", () => {
const { root, parent } = createFixture();
const renderer = createStatusBarRenderer(parent);
renderer.render([{ key: "count", text: " ", value: 1234 }]);
const item = findItem(root, "count");
const value = findValue(item);
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
renderer.render([]);
expect(item.classList.contains("livesync-stat-inactive")).toBe(true);
expect(item.hidden).toBe(false);
expect(item.getAttribute("aria-hidden")).toBe("true");
expect(item.textContent).toBe(" 1234");
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
renderer.render([{ key: "count", text: " ", value: 5 }]);
expect(findItem(root, "count")).toBe(item);
expect(item.classList.contains("livesync-stat-inactive")).toBe(false);
expect(item.getAttribute("aria-hidden")).toBeNull();
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch");
renderer.dispose();
});
it("keeps inactive items in place when other items appear and does not repeat inactive mutations", () => {
const { document, root, parent } = createFixture();
const renderer = createStatusBarRenderer(parent);
const first = { key: "first", text: "first " };
const count = { key: "count", text: "count ", value: 1234 };
const last = { key: "last", text: "last" };
renderer.render([first, count, last]);
const initialOrder = [...root.children];
const omitted = findItem(root, "count");
renderer.render([first, last]);
expect(root.children.map((item) => initialOrder.indexOf(item))).toEqual([0, 1, 2]);
expect(visibleItems(root)).toEqual([initialOrder[0], initialOrder[2]]);
const mutations = document.mutations;
renderer.render([first, last]);
expect(document.mutations).toBe(mutations);
const added = { key: "added", text: "added " };
renderer.render([first, added, last]);
expect(root.children.map((item) => item.textContent)).toEqual(["first ", "count 1234", "added ", "last"]);
expect(omitted.textContent).toBe("count 1234");
expect(findValue(omitted).style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
renderer.render([first, count, added, last]);
expect(findItem(root, "count")).toBe(omitted);
expect(visibleItems(root)).toEqual(root.children);
renderer.dispose();
});
it("reorders existing nodes without replacing them", () => {
const { root, parent } = createFixture();
const renderer = createStatusBarRenderer(parent);
renderer.render([
{ key: "first", text: "first " },
{ key: "second", text: "second " },
]);
const first = findItem(root, "first");
const second = findItem(root, "second");
renderer.render([
{ key: "second", text: "second " },
{ key: "first", text: "first " },
]);
expect(visibleItems(root)).toEqual([second, first]);
expect(findItem(root, "first")).toBe(first);
expect(findItem(root, "second")).toBe(second);
renderer.dispose();
});
it("removes its nodes on dispose and ignores later renders", () => {
const { root, parent } = createFixture();
const externalNode = new TestElement(new TestDocument(), "strong");
root.appendChild(externalNode);
const renderer = createStatusBarRenderer(parent);
renderer.render([{ key: "sync", text: "sync" }]);
const item = findItem(root, "sync");
renderer.dispose();
const childCountAfterDispose = root.childNodes.length;
renderer.dispose();
renderer.render([{ key: "later", text: "later" }]);
expect(item.parentNode).toBeUndefined();
expect(root.childNodes).toEqual([externalNode]);
expect(root.childNodes).toHaveLength(childCountAfterDispose);
});
});
+25 -2
View File
@@ -19,6 +19,22 @@ import { initialiseWorkerModule } from "@vrtmrz/livesync-commonlib/compat/worker
import { manifestVersion, packageVersion } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvVars";
import { VaultScanResults } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
const OBSIDIAN_PLATFORMS = new Set([
"android-app",
"ios",
"macos",
"mobile-app",
"mobile",
"safari",
"desktop",
"desktop-app",
"unknown-obsidian",
]);
function isObsidianPlatform(platform: string): boolean {
return OBSIDIAN_PLATFORMS.has(platform);
}
export class ModuleLiveSyncMain extends AbstractModule {
async _onLiveSyncReady() {
if (!(await this.core.services.appLifecycle.onLayoutReady())) return false;
@@ -44,13 +60,20 @@ export class ModuleLiveSyncMain extends AbstractModule {
}
}
// Ordinary start-up may continue when individual files could not be
// processed. Explicit Fetch and Rebuild flows retain the strict default.
// processed. Explicit Fetch and Rebuild flows remain strict.
const { ignoreSuspending = false, continueOnFileFailure = true } = this.core.startupDatabaseOptions;
// Only ordinary Obsidian start-up honours the opt-out. Keep other hosts
// and explicit operation modes strict.
const strictContentCheck =
!isObsidianPlatform(this.services.API.getPlatform()) ||
this.settings.strictContentCheckOnStartup === true ||
this.core.startupDatabaseOptions.continueOnFileFailure === false;
const initialisationResult = await this.services.databaseEvents.initialiseDatabase(
false,
false,
ignoreSuspending,
continueOnFileFailure
continueOnFileFailure,
{ strictContentCheck }
);
if (initialisationResult === VaultScanResults.FAILED) {
this._log($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff. Show more