Compare commits

..
Author SHA1 Message Date
vorotamoroz 415f81d533 Merge pull request #1151 from vrtmrz/docs/service-feature-guidance
Document service feature and legacy Module boundaries
2026-08-30 19:13:56 +09:00
vorotamoroz 91625a1c76 Document service feature and legacy Module boundaries 2026-08-30 10:10:58 +00:00
6 changed files with 282 additions and 224 deletions
+26 -31
View File
@@ -129,34 +129,28 @@ Changes spanning both repositories must first produce a packed Commonlib artefac
## Architecture
### Module System
### Service composition and legacy Modules
The plugin uses a dynamic module system to reduce coupling and improve maintainability:
The application is composed from Services, ServiceModules, serviceFeatures, add-ons, and a legacy Module layer:
- **Service Hub**: Central registry for services using dependency injection
- Services are registered, and accessed via `this.services` (in most modules)
- **Module Loading**: All modules extend `AbstractModule` or `AbstractObsidianModule` (which extends `AbstractModule`). These modules are loaded in main.ts and some modules.
- **Module Categories** (by directory):
- `core/` - Platform-independent core functionality
- `coreObsidian/` - Obsidian-specific core (e.g., `ModuleFileAccessObsidian`)
- `essential/` - Required modules (e.g., `ModuleMigration`, `ModuleKeyValueDB`)
- `features/` - Optional features (e.g., `ModuleLog`, `ModuleObsidianSettings`)
- `extras/` - Development/testing tools (e.g., `ModuleDev`, ~~`ModuleIntegratedTest`~~)
- **Services**: Core services (e.g., `database`, `replicator`, `storageAccess`) are registered in `ServiceHub` and accessed by modules. They provide an extension point for add new behaviour without modifying existing code.
- For example, checks before the replication can be added to the `replication.onBeforeReplicate` handler, and the handlers can be return `false` to prevent replication-starting. `vault.isTargetFile` also can be used to prevent processing specific files.
- **ServiceModule**: A new type of module that directly depends on services.
- **Service Hub**: the long-lived registry of service contracts. Add a simple extension, such as a pre-replication check, to the handler owned by the relevant Service.
- **ServiceModule**: a host-created, long-lived operational capability shared through the typed `ServiceModules` record. Current examples include storage access, file handling, and database rebuilding.
- **serviceFeature**: a typed composition function which accepts only its declared Services and ServiceModules. It registers lifecycle handlers, commands, user-interface bindings, or other host glue, and may return a focused view. It is not a runtime registry entry.
- **AbstractModule** and **AbstractObsidianModule**: the legacy application Module layer. Existing Modules remain supported, but their broad core access and two-phase binding are not the preferred dependency boundary for new composition.
#### Note on Module vs Service
Mutable state is permitted in a serviceFeature. State alone is not a reason to introduce a class, ServiceModule, or legacy Module. Prefer a private context and module-level functions unless stable identity, polymorphism, shared resource ownership, replacement, abort, or disposal is part of the contract.
After v0.25.44 refactoring, the Service will henceforth, as a rule, cease to use setHandler, that is to say, simple lazy binding. - They will be implemented directly in the service. - However, not everything will be middlewarised. Modules that maintain state or make decisions based on the results of multiple handlers are permitted.
Use interaction-based, London School unit tests at the composition boundary. Verify collaborator calls, ordering, failure short-circuiting, and handler registration. If a test needs a broad core fixture, a deep mock chain, manual prototype invocation, or unrelated Services, treat that friction as a design-review signal.
Hence, the new feature should be implemented as follows:
See [Service feature and legacy Module boundaries](docs/design_docs/service_feature_and_legacy_module_boundaries.md) for the selection criteria, current examples, reasons to avoid new `AbstractModule` subclasses, incremental migration guidance, and test shapes. Commonlib's [service feature composition guide](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/service-feature-composition.md) defines the shared host-neutral boundary.
- If it is a simple extension point (e.g., adding a check before replication), it should be implemented as a handler in the service (e.g., `replication.onBeforeReplicate`).
- If it requires maintaining state or making decisions based on multiple handlers, it should be implemented as a serviceModule dependent on the relevant services explicitly.
- If you have to implement a new feature without much modification, you can extent existing modules, but it is recommended to implement a new module or serviceModule for better maintainability.
- Refactoring existing modules to services is also always welcome!
- Please write tests for new features, you will notice that the simple handler approach is quite testable.
Legacy Modules remain grouped by directory:
- `core/` contains platform-independent core behaviour;
- `coreObsidian/` contains Obsidian-specific core behaviour;
- `essential/` contains required Modules;
- `features/` contains optional features; and
- `extras/` contains development and testing tools.
### Key Architectural Components
@@ -227,20 +221,21 @@ Commonlib owns the typed English fallback for messages requested by its services
## Common Patterns
### Module Implementation (Now not recommended for new features, use services instead)
### Service feature implementation
```typescript
export class ModuleExample extends AbstractObsidianModule {
async _everyOnloadStart(): Promise<boolean> {
/* ... */
}
type ExampleHost = NecessaryServices<"appLifecycle" | "API", never>;
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.appLifecycle.handleOnInitialise(this._everyOnloadStart.bind(this));
}
}
export const useExampleFeature = createServiceFeature((host: ExampleHost) => {
host.services.appLifecycle.onLoaded.addHandler(async () => {
host.services.API.addLog("Example feature loaded");
return true;
});
});
```
Existing legacy Modules continue to register their handlers in `onBindFunction()`. Follow [Service feature and legacy Module boundaries](docs/design_docs/service_feature_and_legacy_module_boundaries.md) when new behaviour touches one of those Modules.
### Settings Management
- Settings are defined by Commonlib (`ObsidianLiveSyncSettings`)
@@ -0,0 +1,250 @@
---
date: 2026-08-30
commonlib-version: "0.1.19"
self-hosted-livesync-version: "1.0.21"
status: accepted
---
# Service feature and legacy Module boundaries
## Purpose
This document guides new Self-hosted LiveSync composition and bounded refactoring of existing application Modules. It supplements Commonlib's [service feature composition guide](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/service-feature-composition.md) with the risks and migration boundaries specific to `AbstractModule` and `AbstractObsidianModule`.
Existing Modules remain supported application structures. This guidance does not require mechanical conversion of working code. It defines why a new feature should normally use an existing Service handler or a serviceFeature, and when retaining a Module is still appropriate.
## Default decision
For new behaviour:
1. add a handler to an existing Service when that Service already owns the result, priority, and lifecycle;
2. use a serviceFeature when the work composes several Services, ServiceModules, lifecycle events, commands, or host effects;
3. keep feature-local state in a private context, with functions which receive that context;
4. use a ServiceModule only when several consumers need the same long-lived operational capability or resource lifetime; and
5. use a focused class when stable identity, polymorphism, serialised ownership, replacement, `abort()`, `close()`, or `dispose()` is part of the contract.
Do not select `AbstractModule` or `AbstractObsidianModule` merely to obtain convenient access to `LiveSyncBaseCore`, settings, Services, or Obsidian APIs.
## How the legacy Module layer works
`LiveSyncBaseCore` currently composes the application in this order:
1. retain the constructed Service Hub;
2. construct the `ServiceModules` record;
3. construct and register built-in and host-supplied Modules;
4. compose the built-in Commonlib serviceFeatures;
5. compose host-supplied serviceFeatures;
6. construct add-ons; and
7. call `onBindFunction()` for each registered Module.
The Module constructor therefore runs before its handler bindings, while the complete Service Hub and ServiceModules already exist. `bindModuleFunctions()` then invokes every `onBindFunction()` and runs `__$checkInstanceBinding()`. That diagnostic compares underscore-prefixed prototype methods with method references found in the source text of `onBindFunction()`.
This is a compatibility lifecycle. A serviceFeature does not need to wait for Module binding. It can consume the already constructed Services and ServiceModules directly.
## Why new code should avoid `AbstractModule`
### Dependencies are broader than the type signature
An `AbstractModule` constructor receives `LiveSyncBaseCore`. Through that one object, a subclass can reach:
- the complete Service Hub;
- every ServiceModule;
- the active local database;
- settings and setting persistence;
- application commands, views, ribbon icons, and protocol handlers; and
- path, readiness, logging, and test helpers.
A reader cannot determine the real dependency set from the constructor or class declaration. A serviceFeature using `NecessaryServices` makes that set visible and compiler-checked.
### Initialisation is split across construction and binding
Module fields can dereference `this.services` during class field initialisation, while public behaviour is registered later in `onBindFunction()`. Correctness consequently depends on both the host construction order and a second binding phase.
This permits states which are difficult to express in a type:
- the class exists but its handlers are not registered;
- a field has captured a Service before the intended lifecycle point;
- a method passed as a callback has lost its receiver; or
- a test invokes `onBindFunction()` against a partial object which could not occur through ordinary composition.
### Callback safety is checked at runtime
Legacy Modules commonly register `this.method.bind(this)`. `__$checkInstanceBinding()` can report an underscore-prefixed method which is not referenced by `onBindFunction()`, but it does not type-check the registration or prove that a callback retains its receiver. A module-level function receiving an explicit context does not have a receiver to lose.
### Registry and ordering dependencies remain implicit
Modules are stored in one runtime list. Construction order, binding order, `getModule()`, and subclass identity can become hidden dependencies. A serviceFeature is called at the composition root and returns only an intentionally retained view, so its consumers do not need a general Module locator.
### Resource ownership is not part of the base contract
`AbstractModule` has no standard replacement, cancellation, or disposal contract. Individual Modules can register `onUnload` handlers, but accepting the core does not state which object owns a queue, remote handle, room, timer, or in-flight operation.
Use a focused owner when the resource lifetime is meaningful, then compose that owner through a serviceFeature. The owner should expose the smallest necessary `abort()`, `close()`, `dispose()`, or view contract.
### Tests inherit unrelated application structure
Current Module tests sometimes call a prototype method with manually assembled objects:
```typescript
ModuleReplicator.prototype.onBindFunction.call(module, {} as never, services as never);
```
Other tests construct a broad fake core so that the base class can expose one or two collaborators. These tests can verify behaviour, but the fixture cost obscures the actual interaction contract and makes unrelated Service changes more likely to affect them.
When a focused London School test requires a broad core fixture, repeated `as never`, deep mock chains, or manual prototype invocation, treat that friction as a design-review signal.
## Why `AbstractObsidianModule` is a more restrictive boundary
`AbstractObsidianModule` adds direct access to the plug-in and `app` on top of the complete core. This is useful for existing Obsidian-owned integration, but it combines platform policy, application composition, and domain behaviour in one inheritance boundary.
For new behaviour, keep Obsidian-specific presentation or registration in an Obsidian-owned serviceFeature. Pass host-neutral operations or focused views into that feature. This permits the CLI, WebApp, WebPeer, and unit tests to reuse the operation without constructing an Obsidian plug-in.
## Current examples
### A small serviceFeature: language initialisation
`src/serviceFeatures/onLayoutReady/enablei18n.ts` declares only `setting`, `API`, and `appLifecycle`:
```typescript
export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API, appLifecycle } }) => {
// Apply the language, persist a change, and register unload clean-up.
});
```
The local `ObsidianLanguageAppliedNotice` class is still appropriate. It owns one replaceable Obsidian `Notice` and has an explicit `clear()` lifetime operation. The class is not used as a service locator, and the serviceFeature owns its construction and host binding.
### Operation and composition: database preparation
Commonlib's `prepareDatabaseForUse()` is independently callable and receives explicit collaborators. `usePrepareDatabaseForUse()` constructs the error manager and registers the operation with `databaseEvents.initialiseDatabase`.
This split allows tests to verify:
- database opening before scanning;
- short-circuiting after a failed step;
- completion handlers before pending-event commitment;
- readiness only after every required step; and
- registration of the composed operation.
The operation does not need an application Module identity.
### Private state and ordered handlers: target filters
Commonlib's `targetFilter.ts` keeps each cache or readiness gate in the factory which owns one predicate. `useTargetFilters()` constructs those predicates and registers them in their required order.
The state remains private to the composed feature. It does not become a `LiveSyncBaseCore` property or a ServiceModule merely because it persists across calls.
### Legacy example to improve when touched: conflict checking
`ModuleConflictChecker` currently combines:
- conflict policy decisions;
- two `QueueProcessor` owners;
- cancellation signalling;
- access to settings and active-file state; and
- registration into the conflict Service.
Its queues are class fields which dereference `this.services` during field initialisation, and its public handlers are bound later in `onBindFunction()`.
A bounded change to this area should prefer a shape such as:
```typescript
interface ConflictCheckContext {
readonly checkQueue: QueueProcessor<FilePathWithPrefix, unknown>;
readonly resolveQueue: QueueProcessor<FilePathWithPrefix, unknown>;
}
interface ConflictCheckDependencies {
readonly conflict: ConflictCapability;
readonly currentSettings: () => ConflictSettings;
readonly getActiveFilePath: () => FilePathWithPrefix | undefined;
readonly log: LogFunction;
}
function queueConflictCheck(
context: ConflictCheckContext,
dependencies: ConflictCheckDependencies,
path: FilePathWithPrefix
): Promise<void> {
// Make the decision and enqueue through explicit collaborators.
}
export function useConflictChecking(host: ConflictCheckingHost): void {
const context = createConflictCheckContext(host);
host.services.conflict.queueCheckFor.setHandler((path) => queueConflictCheck(context, dependencies, path));
}
```
The exact extraction should be made only when conflict-checking behaviour changes. The example describes the intended ownership boundary; it is not a request to convert the Module in an unrelated documentation change.
## Interaction-based testing
Test a serviceFeature at two levels.
First, test the operation or state owner with narrow collaborators:
```typescript
it("does not enqueue after an optional resolver completes the conflict", async () => {
const enqueue = vi.fn();
const resolveOptionally = vi.fn(async () => true);
await queueConflictCheck(contextWith({ enqueue }), dependenciesWith({ resolveOptionally }), path);
expect(resolveOptionally).toHaveBeenCalledWith(path);
expect(enqueue).not.toHaveBeenCalled();
});
```
Second, test the composition:
```typescript
it("registers conflict checking with the conflict Service", () => {
const setHandler = vi.fn();
useConflictChecking(makeHost({ setHandler }));
expect(setHandler).toHaveBeenCalledOnce();
expect(setHandler).toHaveBeenCalledWith(expect.any(Function));
});
```
The test should make the interaction contract legible: which collaborator is called, in which order, what result is returned, and what must not run after a failure.
Do not expose a private constructor, publish a broad mock, or attach a context to `LiveSyncBaseCore` solely to make a test possible. If the narrow test cannot be written cleanly, reconsider the responsibility split.
## When retaining a Module is appropriate
Retain or extend an existing Module when the current change depends on its established:
- Module identity or `getModule()` lookup;
- binding order with neighbouring legacy Modules;
- Obsidian plug-in lifecycle integration;
- user interface object lifetime; or
- compatibility behaviour whose extraction would materially expand the change.
Even then, new domain operations can receive explicit dependencies instead of accepting the Module or complete core. Improve the affected ownership boundary without converting unrelated neighbours.
## Migration approach
When a Module is already in scope:
1. name the behaviour being changed and the state or resource which owns it;
2. identify the smallest operation which can accept explicit dependencies;
3. add a focused regression or interaction test around that operation;
4. keep host-specific registration in the Module initially, if that is the smallest safe step;
5. move registration to a serviceFeature only when the current integration can do so without changing ordering or lifetime; and
6. remove the legacy Module only when no identity, lookup, ordering, or compatibility consumer remains.
This is an incremental boundary change, not an inheritance-removal campaign.
## Review checklist
Before adding or changing application composition, confirm that:
- dependencies are visible in a function, context, or constructor type;
- mutable state has one named owner;
- shared state is not promoted to a ServiceModule without multiple consumers;
- external resources have explicit replacement and disposal semantics;
- host-specific UI remains outside host-neutral operations;
- a consumer receives a focused view rather than the complete core;
- handler ordering and failure short-circuiting are tested; and
- retaining a legacy Module is an explicit compatibility decision.
-9
View File
@@ -291,15 +291,6 @@ export async function adjustSettingToRemote(
return true;
}
if (operation === "rebuild") {
// An overwrite makes this device authoritative for both the Vault contents and the
// shared synchronisation settings. The remote lookup above remains a connection
// preflight, but settings from the database which is about to be replaced must not
// overwrite intentional local changes such as enabling E2EE.
log("Rebuild will use this device's synchronisation settings.", LOG_LEVEL_NOTICE);
return true;
}
const remoteTweaks = remoteResult.values;
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
// Check if any necessary tweak value is different from current config.
+1 -37
View File
@@ -1149,33 +1149,6 @@ describe("Red Flag Feature", () => {
});
describe("Remote configuration adjustment", () => {
it("keeps this device's E2EE settings when preparing to overwrite the remote", async () => {
const host = createHostMock();
Object.assign(host.mocks.setting.settings, TweakValuesShouldMatchedTemplate, {
encrypt: true,
passphrase: "local-encryption-passphrase",
});
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
availableRemoteTweaks({
...TweakValuesShouldMatchedTemplate,
encrypt: false,
})
);
const result = await adjustSettingToRemote(
host as any,
createLoggerMock(),
host.mocks.setting.currentSettings(),
"rebuild"
);
expect(result).toBe(true);
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledOnce();
expect(host.mocks.setting.currentSettings().encrypt).toBe(true);
expect(host.mocks.setting.currentSettings().passphrase).toBe("local-encryption-passphrase");
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
});
it("should skip remote configuration fetch when preventFetchingConfig is true", async () => {
const host = createHostMock();
const config = { preventFetchingConfig: true } as any;
@@ -1882,15 +1855,8 @@ describe("Red Flag Feature", () => {
it("should handle rebuildAll flag with flagHandlerToEventHandler", async () => {
const host = createHostMock();
const log = createLoggerMock();
Object.assign(host.mocks.setting.settings, TweakValuesShouldMatchedTemplate, {
encrypt: true,
passphrase: "local-encryption-passphrase",
});
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
availableRemoteTweaks({
...TweakValuesShouldMatchedTemplate,
encrypt: false,
})
availableRemoteTweaks({ customChunkSize: 1 })
);
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
@@ -1902,8 +1868,6 @@ describe("Red Flag Feature", () => {
await Promise.resolve(eventHandler());
await new Promise((resolve) => setTimeout(resolve, 10));
expect(host.mocks.rebuilder.$rebuildEverything).toHaveBeenCalled();
expect(host.mocks.setting.currentSettings().encrypt).toBe(true);
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalled();
});
@@ -1,15 +1,12 @@
import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { DEVICE_ID_PREFERRED, MILESTONE_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
deleteCouchDbDatabase,
fetchCouchDbDocument,
loadCouchDbConfig,
makeUniqueDatabaseName,
putCouchDbDocument,
waitForCouchDbDocs,
type CouchDbConfig,
} from "../runner/couchdb.ts";
@@ -31,7 +28,7 @@ import {
continueWithoutRemoteSettings,
type SetupArtifact,
} from "../runner/setupUri.ts";
import { captureObsidianPage, openLiveSyncSettings, withObsidianPage } from "../runner/ui.ts";
import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
@@ -47,10 +44,6 @@ const captures = {
scenario: "couchdb-manual-setup-workflow",
guide: "couchdb-manual",
} as const;
const e2eeRebuildCaptures = {
scenario: "couchdb-manual-setup-workflow",
guide: "couchdb-manual-e2ee-rebuild",
} as const;
type RunnerContext = {
binary: string;
@@ -118,7 +111,9 @@ async function enterManualCouchDBSettings(port: number, couchDb: CouchDbConfig,
await withObsidianPage(port, async (page) => {
const method = modalByTitle(page, "Connection Method");
await selectRadioOption(method, "Configure a remote manually");
await method.getByRole("button", { name: "Proceed with manual configuration" }).click({ timeout: uiTimeoutMs });
await method
.getByRole("button", { name: "Proceed with manual configuration" })
.click({ timeout: uiTimeoutMs });
const encryption = modalByTitle(page, "End-to-End Encryption");
await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs });
@@ -251,111 +246,6 @@ async function waitForRemoteEntry(context: RunnerContext, entry: { id: string; c
});
}
async function assertPersistedE2EE(vault: TemporaryVault): Promise<void> {
const persisted = JSON.parse(
await readFile(join(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json"), "utf8")
) as {
encrypt?: unknown;
encryptedPassphrase?: unknown;
passphrase?: unknown;
};
assertEqual(persisted.encrypt, true, "Manual CouchDB setup did not persist E2EE as enabled.");
assertEqual(persisted.passphrase, "", "Manual CouchDB setup persisted the E2EE passphrase in plain text.");
if (typeof persisted.encryptedPassphrase !== "string" || persisted.encryptedPassphrase.length === 0) {
throw new Error("Manual CouchDB setup did not persist an encrypted E2EE passphrase.");
}
}
async function setRemotePreferredE2EEDisabled(context: RunnerContext): Promise<void> {
const milestone = await fetchCouchDbDocument(context.couchDb, context.dbName, MILESTONE_DOCID);
const tweakValues = milestone.tweak_values;
if (typeof tweakValues !== "object" || tweakValues === null || Array.isArray(tweakValues)) {
throw new Error("The existing CouchDB milestone did not contain synchronisation settings.");
}
const preferred = (tweakValues as Record<string, unknown>)[DEVICE_ID_PREFERRED];
if (typeof preferred !== "object" || preferred === null || Array.isArray(preferred)) {
throw new Error("The existing CouchDB milestone did not contain preferred synchronisation settings.");
}
await putCouchDbDocument(context.couchDb, context.dbName, {
...milestone,
tweak_values: {
...tweakValues,
[DEVICE_ID_PREFERRED]: {
...(preferred as Record<string, unknown>),
encrypt: false,
},
},
});
}
async function assertRemotePreferredE2EE(context: RunnerContext, expected: boolean): Promise<void> {
const milestone = await fetchCouchDbDocument(context.couchDb, context.dbName, MILESTONE_DOCID);
const tweakValues = milestone.tweak_values;
const preferred =
typeof tweakValues === "object" && tweakValues !== null && !Array.isArray(tweakValues)
? (tweakValues as Record<string, unknown>)[DEVICE_ID_PREFERRED]
: undefined;
const encrypt =
typeof preferred === "object" && preferred !== null && !Array.isArray(preferred)
? (preferred as Record<string, unknown>).encrypt
: undefined;
assertEqual(encrypt, expected, `The remote preferred E2EE setting was not ${expected ? "enabled" : "disabled"}.`);
}
async function scheduleRemoteOverwrite(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
const maintenance = await settingsNavigator.openPage("Maintenance");
const overwrite = maintenance
.locator(".setting-item")
.filter({ hasText: "Overwrite Server Data with This Device's Files" });
await overwrite
.getByRole("button", { name: "Schedule and Restart", exact: true })
.click({ timeout: uiTimeoutMs });
});
}
async function assertRemoteEntryEncrypted(
context: RunnerContext,
entry: { id: string; path: string; children: string[] },
plaintextPath: string,
plaintext: string
): Promise<void> {
const remoteMetadata = await fetchCouchDbDocument(context.couchDb, context.dbName, entry.id);
const serialisedMetadata = JSON.stringify(remoteMetadata);
if (
!remoteMetadata._id.startsWith("f:") ||
typeof remoteMetadata.path !== "string" ||
!remoteMetadata.path.startsWith("/\\:") ||
remoteMetadata.path === entry.path ||
serialisedMetadata.includes(plaintextPath) ||
!Array.isArray(remoteMetadata.children) ||
remoteMetadata.children.length !== 0 ||
remoteMetadata.mtime !== 0 ||
remoteMetadata.ctime !== 0 ||
remoteMetadata.size !== 0
) {
throw new Error("The directly fetched CouchDB Metadata document did not protect its properties.");
}
const childId = entry.children[0];
if (!childId) {
throw new Error("The local E2EE test entry did not reference a Chunk document.");
}
if (!childId.startsWith("h:+")) {
throw new Error(`The E2EE test entry used an unencrypted Chunk identifier: ${childId}`);
}
const remoteChunk = await fetchCouchDbDocument(context.couchDb, context.dbName, childId);
assertEqual(remoteChunk.e_, true, "The directly fetched CouchDB Chunk was not marked as encrypted.");
if (
typeof remoteChunk.data !== "string" ||
remoteChunk.data === plaintext ||
remoteChunk.data.includes(plaintext)
) {
throw new Error("The directly fetched CouchDB Chunk contained readable Vault content.");
}
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
@@ -398,37 +288,11 @@ async function main(): Promise<void> {
1,
"Manual CouchDB setup did not persist exactly one remote profile."
);
await assertPersistedE2EE(vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent);
const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForRemoteEntry(context, entry);
} catch (error) {
await captureFailure(session, "first-device");
throw error;
} finally {
await stopTrackedSession(context, session);
}
await setRemotePreferredE2EEDisabled(context);
await assertRemotePreferredE2EE(context, false);
session = await startUnconfiguredSession(context, vaultA);
try {
await scheduleRemoteOverwrite(session.remoteDebuggingPort);
screenshots.push(await confirmRebuild(session.remoteDebuggingPort, e2eeRebuildCaptures));
screenshots.push(
await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, e2eeRebuildCaptures)
);
await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await assertPersistedE2EE(vaultA);
const rebuiltEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
await waitForRemoteEntry(context, rebuiltEntry);
await assertRemoteEntryEncrypted(context, rebuiltEntry, notePath, noteContent);
await assertRemotePreferredE2EE(context, true);
const generated = await generateSetupURIFromDevice(
session.remoteDebuggingPort,
@@ -438,7 +302,7 @@ async function main(): Promise<void> {
secondDeviceArtifact = generated.artifact;
screenshots.push(...generated.screenshots);
} catch (error) {
await captureFailure(session, "e2ee-rebuild");
await captureFailure(session, "first-device");
throw error;
} finally {
await stopTrackedSession(context, session);
-6
View File
@@ -12,12 +12,6 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
## Unreleased
### Synchronisation and storage
#### Fixed
- **Overwrite Server Data with This Device's Files** now keeps this device's synchronisation settings instead of reapplying settings from the remote database which is about to be replaced. Enabling E2EE before a rebuild therefore remains enabled and uploads encrypted data. (#1146)
## 1.0.21
26th August, 2026