mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-01 16:27:07 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c44f7d0f8 | ||
|
|
e040002633 | ||
|
|
8e506fdbc7 | ||
|
|
cab6679c2b | ||
|
|
f737701695 | ||
|
|
565749ef6c | ||
|
|
32c7cc8aca | ||
|
|
5c5f454286 | ||
|
|
c4d9f8687e | ||
|
|
bd0581cdc9 | ||
|
|
9592ce8529 | ||
|
|
e2d1bb09ae | ||
|
|
3f76ad796e | ||
|
|
1d2077d3fc | ||
|
|
24228bf7cf | ||
|
|
9a28d46287 | ||
|
|
3a23b7a6b0 | ||
|
|
23dd4ab87e | ||
|
|
6364988453 | ||
|
|
76318944a4 | ||
|
|
704e141fd9 | ||
|
|
69cd6250d2 | ||
|
|
72f033fca4 | ||
|
|
415f81d533 | ||
|
|
91625a1c76 | ||
|
|
a5756503a2 | ||
|
|
7300db08d6 | ||
|
|
db70b4c2b6 | ||
|
|
f7206b1a6e | ||
|
|
fc160ee060 | ||
|
|
22b0cc133a | ||
|
|
948f961caa | ||
|
|
ad83ae858d | ||
|
|
cc000f2cdf | ||
|
|
d24bda88be | ||
|
|
4dcc783a71 | ||
|
|
be03c25904 | ||
|
|
7cf4ec49ed | ||
|
|
8e5b058eef | ||
|
|
56444bb98b | ||
|
|
7aa41baf08 |
@@ -129,34 +129,38 @@ 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 stateful or resource-owning 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 are loaded by the application and bound after the Service graph has been composed; this broad core access is not the preferred dependency boundary for new orchestration.
|
||||
|
||||
#### Note on Module vs Service
|
||||
The normal composition order is the Service Hub, replicator-provider registration, ServiceModules, serviceFeatures, add-ons, and finally legacy Module binding. A serviceFeature may therefore consume an already constructed ServiceModule. Preferring a serviceFeature for new composition is a dependency-boundary rule, not an initialisation-order rule.
|
||||
|
||||
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.
|
||||
Mutable state is permitted in a serviceFeature. State alone is not a reason to create a class, a ServiceModule, or retain an AbstractModule. Prefer one private context, with module-level functions which receive that context, when identity and polymorphism are not part of the contract. Separate the state, transitions, and invariants from the surrounding function which registers lifecycle handlers and connects downstream effects. Give the stateful boundary narrow collaborators rather than `LiveSyncBaseCore`.
|
||||
|
||||
Hence, the new feature should be implemented as follows:
|
||||
Use a class when stable object identity, replaceable implementations, or an explicit external-resource lifecycle such as serialised ownership, `dispose()`, or `abort()` is part of the contract. Use a ServiceModule when that operational capability or resource lifecycle must also be shared explicitly by several consumers. Do not introduce a class merely to group dependencies or make private functions callable.
|
||||
|
||||
- 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.
|
||||
Several narrow views over one lifetime do not require several state owners or a public façade class. One private context may back all of those views, provided that the context remains private and each consumer receives only its declared contract. Keep actual resource owners separate when identity, serialised replacement, abort, retirement, or disposal order is part of their behaviour.
|
||||
|
||||
When a core-owned serviceFeature returns a view needed by one host-specific consumer, pass that view through host composition instead of storing it as a public `LiveSyncBaseCore` property or promoting it to a ServiceModule. The receiving host should inject the view into the narrow command or application context which uses it.
|
||||
|
||||
Commonlib's `targetFilter.ts` and `prepareDatabaseForUse.ts` demonstrate the intended split: focused factories or operations own their private state and behaviour, while the corresponding `use...` function composes dependencies and registers handlers. The P2P composition follows the same direction at a larger scale by separating durable policy and room-session ownership from host lifecycle and user-interface wiring. Existing Modules do not apply this boundary consistently; improve the affected boundary when changing their behaviour rather than performing an unrelated mechanical conversion.
|
||||
|
||||
Use interaction-based, London School unit tests for the composition boundary. Verify collaborator calls, ordering, failure short-circuiting, and handler registration, then test the focused state owner for its transitions and invariants. If a test needs a broad core fixture, a large class mock, deep mock chains, or unrelated Services, treat that friction as a design-review signal and consider a private context with narrower functions before adding more test machinery.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -165,7 +169,7 @@ Hence, the new feature should be implemented as follows:
|
||||
- **Service Hub** (`src/modules/services/`): Central service registry using dependency injection
|
||||
- **Common Library** (`@vrtmrz/livesync-commonlib`): Platform-independent synchronisation logic, shared with the CLI, WebApp, WebPeer, and external tools
|
||||
|
||||
Commonlib owns the P2P replicator and Trystero transport lifecycle. Host commands, event handlers, and views must retain the Commonlib service-feature result and resolve its current `replicator` at the point of use. They must not snapshot an instance which can be replaced when settings or the local database change, close Trystero-owned raw peers, or install another Trystero transport generation at the application root.
|
||||
Commonlib owns one stable `LiveSyncP2PService`, its `P2PRoomSessionOwner`, and the replaceable Trystero room session. Host commands, event handlers, and views consume the focused transport, connection-probe admission, directory, peer-admission, transfer, change-relay, configuration, and diagnostic views returned by the service feature. They must not retain the deprecated compatibility Replicator as an ordinary service locator, close Trystero-owned raw peers, or install another Trystero transport generation at the application root. The exact as-built ownership and shutdown boundaries are recorded in Commonlib's `docs/p2p-transport-lifecycle.md` design document.
|
||||
|
||||
### Conflict Merge Policy
|
||||
|
||||
@@ -227,20 +231,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,758 @@
|
||||
---
|
||||
date: 2026-08-27
|
||||
commonlib-version: "0.1.20"
|
||||
self-hosted-livesync-version: "1.0.21"
|
||||
status: proposed
|
||||
series: replicator-capabilities-and-lifecycle
|
||||
part: 1 of 3
|
||||
---
|
||||
|
||||
# Architectural Decision Record: Replicator Capabilities and Lifecycle Orchestration — Part 1: Core Contract
|
||||
|
||||
Series navigation: this is Part 1 of 3. Continue with [Part 2: P2P service and
|
||||
session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md),
|
||||
then [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md).
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. This record defines the provider, capability, lifecycle, interaction,
|
||||
ownership, and probe boundaries required by current Self-hosted LiveSync
|
||||
consumers. It is the generic part of the series; the P2P-specific ownership
|
||||
rules live in Part 2, and implementation sequencing lives in Part 3.
|
||||
|
||||
The accepted P2P room and transport lifecycle record remains authoritative for
|
||||
the current P2P implementation until Stage 3 in Part 3 is complete. The
|
||||
supersession boundary for that record is stated in Part 2 and is not repeated
|
||||
here.
|
||||
|
||||
## Context
|
||||
|
||||
Self-hosted LiveSync currently represents CouchDB, Object Storage, and P2P with
|
||||
one `LiveSyncAbstractReplicator` base class. That base class requires finite
|
||||
and continuous replication, full upload and download, remote creation and
|
||||
reset, lock administration, preferred-tweak Metadata, on-demand Chunk reads,
|
||||
remote status, integrity inspection, and connected-device inspection.
|
||||
|
||||
These operations do not share one support boundary:
|
||||
|
||||
- CouchDB supports unattended OneShot Sync, Continuous replication,
|
||||
central-remote administration, and CouchDB-specific inspection and
|
||||
maintenance.
|
||||
- Object Storage supports finite journal synchronisation, central reset and
|
||||
lock Metadata, full upload and download, and storage-size inspection. It has
|
||||
no continuous changes feed, CouchDB Chunk source, CouchDB integrity
|
||||
inspection, or CouchDB device registry.
|
||||
- P2P supports peer-targeted finite transfer and an independently owned room,
|
||||
signalling, watch, and broadcast lifecycle. It has no central database to
|
||||
create, reset, lock, inspect for size, or upload during first-device setup.
|
||||
It also has peer-driven AutoSync and AutoWatch paths when the room is open.
|
||||
Those paths are detailed in Part 2.
|
||||
|
||||
The abstract class makes absent capabilities look like operations. Current
|
||||
implementations express absence through thrown errors, `false`, empty arrays,
|
||||
zero counts, silent success, and a dummy all-zero Security Seed. Callers
|
||||
cannot tell whether an operation was performed, was inapplicable, or could not
|
||||
be performed.
|
||||
|
||||
A neutral value is correct only when it is the documented identity for every
|
||||
caller. For example, Object Storage has no remote-Chunk role, whereas a
|
||||
supported CouchDB Chunk request may legitimately return an empty array. A
|
||||
zero integrity count cannot safely stand for an inspection which did not run.
|
||||
|
||||
Issue 1140 exposes the lifecycle consequence. In version 1.0.21,
|
||||
`ModuleReplicatorCouchDB` owns the application resume callback and excludes
|
||||
Object Storage and P2P by `remoteType`. Object Storage accepts `syncOnStart`,
|
||||
but no journal synchronisation starts at resume. Periodic, event-driven, and
|
||||
manual calls later reach the active Replicator successfully.
|
||||
|
||||
The construction boundary is also overloaded. `getNewReplicator()` is an
|
||||
order-dependent first-result handler which ignores false results and catches
|
||||
handler errors. It is used for active Replicator acquisition, trial settings,
|
||||
and temporary command instances. P2P construction can replace and close a
|
||||
service-owned current transport, so an apparently temporary request can
|
||||
disturb an active or adjunct transport.
|
||||
|
||||
Fast Setup is a separate boundary. Streaming Fetch is a CouchDB-specific
|
||||
initial transfer which uses CouchDB HTTP settings and a Security Seed supplier;
|
||||
it does not need a full Replicator or an owned PouchDB connection. It must not
|
||||
be made a generic Replicator capability merely because the current code obtains
|
||||
one as a supplier.
|
||||
|
||||
Finally, current operation results can hide failure. Object Storage can discard
|
||||
a failed or stopped journal result and report success. A headless P2P path can
|
||||
complete but return `undefined`, which the caller interprets as failure. Remote
|
||||
mutations can be caught or ignored before Rebuilder continuation, and an
|
||||
offline integrity inspection can be represented as zero. The contract must
|
||||
make those outcomes truthful.
|
||||
|
||||
## Decision drivers
|
||||
|
||||
The design must:
|
||||
|
||||
1. cover every operation used by the plug-in, CLI, WebApp, WebPeer, Setup,
|
||||
Rebuilder, and maintenance flows;
|
||||
2. keep lifecycle policy independent of provider class names;
|
||||
3. make an omitted support decision a compile-time error when a current
|
||||
provider or capability is added;
|
||||
4. carry trigger and interaction policy through `ReplicationService`, so an
|
||||
automatic trigger cannot open a dialogue;
|
||||
5. distinguish capability absence, unavailable observation, and an observed
|
||||
empty value where that difference affects safety;
|
||||
6. retain simple neutral results where they are safe identities for every
|
||||
caller;
|
||||
7. give active Replicator, flow-specific probe, and transport resources one
|
||||
explicit owner and disposal boundary; and
|
||||
8. report replication, central-remote mutation, and maintenance outcomes
|
||||
truthfully while preserving source compatibility during migration.
|
||||
|
||||
## Decision
|
||||
|
||||
### Use precise ownership terms
|
||||
|
||||
- A **provider definition** is the host-composed, exhaustive declaration for
|
||||
one current remote kind.
|
||||
- The **active Replicator** is the selected main-remote handle owned by
|
||||
`ReplicatorService`.
|
||||
- A **probe** is a short-lived, flow-specific validation resource whose caller
|
||||
owns disposal.
|
||||
- The **P2P service** is the stable Commonlib implementation which supplies
|
||||
narrow P2P contract views. Its room-session ownership is defined in Part 2.
|
||||
- A **room session** and **session epoch** are P2P terms defined in Part 2; an
|
||||
epoch is an internal fence, not a public capability or logical room name.
|
||||
|
||||
An active Replicator is a handle, not necessarily the owner of every transport
|
||||
which it uses. In particular, the active P2P Replicator is a non-owning adapter
|
||||
over the P2P service. Its ownership consequences are specified once in Part 2.
|
||||
|
||||
### Separate policy, provider selection, and the active Replicator
|
||||
|
||||
Application lifecycle policy decides **when** synchronisation is requested.
|
||||
The selected provider and its active Replicator decide **how**, and whether,
|
||||
that request can be performed. A provider module must not subscribe to the
|
||||
application resume lifecycle merely because it can construct a transport.
|
||||
|
||||
Self-hosted LiveSync will compose one LiveSync-owned serviceFeature as the
|
||||
replication scheduling boundary. The serviceFeature creates one private
|
||||
scheduling context and passes it to module-level transition functions. The
|
||||
context contains only scheduling state and narrow collaborators; the functions
|
||||
implement external-poller ownership, Continuous ownership of recurring work,
|
||||
the daemon's satisfied initial OneShot marker, resume coalescing, and
|
||||
periodic-timer reconciliation. They do not register handlers or acquire
|
||||
`LiveSyncBaseCore`.
|
||||
|
||||
The context receives narrow collaborators for readiness and suspension queries,
|
||||
current settings, `ReplicationService`, periodic-timer control, and diagnostic
|
||||
logging. The surrounding serviceFeature owns the context lifetime, lifecycle
|
||||
registration, and adaptation from those Services. It returns a focused control
|
||||
view containing only the daemon operations to select external polling and mark
|
||||
the initial OneShot as satisfied. Core construction passes a frozen bundle of
|
||||
built-in feature views to host composition, without retaining those views as
|
||||
public `LiveSyncBaseCore` properties. The CLI injects the scheduling view into
|
||||
its command context; other hosts may ignore it. No host may expose the context's
|
||||
mutable state or recover it from a core-keyed global or `WeakMap`.
|
||||
|
||||
This boundary is not a ServiceModule merely because it owns state. It neither
|
||||
owns a shared external resource nor supplies a general operational capability
|
||||
to several unrelated consumers. If a future consumer needs a stable shared
|
||||
scheduling capability beyond the focused CLI view, that ownership decision
|
||||
must be reviewed explicitly rather than widening the returned view implicitly.
|
||||
|
||||
The scheduling functions use persisted settings, `ReplicationService`, and
|
||||
the active support declaration. They do not branch on `remoteType` or use
|
||||
`instanceof` as a capability test. Commonlib owns the trigger-aware replication
|
||||
contract; the host owns application lifecycle wiring.
|
||||
|
||||
The existing `onResumed` event remains the eligible-resume boundary after
|
||||
initial readiness, settings application, and visibility recovery. It is not
|
||||
redefined as a once-per-process event. The context-backed functions coalesce
|
||||
duplicate work within one lifecycle generation and preserve readiness and
|
||||
suspension gates:
|
||||
|
||||
- configured Continuous replication starts only through an active Continuous
|
||||
role;
|
||||
- otherwise, configured `syncOnStart` runs through an unattended OneShot role
|
||||
when that role is supported;
|
||||
- an unsupported configured policy produces an explicit unsupported or
|
||||
not-implemented result; and
|
||||
- automatic start-up, periodic, file-event, and merge triggers never open a
|
||||
dialogue. A target-requiring operation is available to an explicit user
|
||||
action or to a flow with a configured target.
|
||||
|
||||
`ReplicationService` remains responsible for readiness checks, bounded finite
|
||||
activity, failure processing, and replication timing. It exposes distinct
|
||||
user-initiated and unattended entry points, or a typed request which carries
|
||||
interaction authority. The scheduling functions never call a concrete
|
||||
Replicator's `openReplication()` directly.
|
||||
|
||||
`P2P_AutoStart` remains a separate P2P room policy. It is not central
|
||||
Continuous replication and is not `syncOnStart`; its service lifecycle is
|
||||
specified in Part 2. Reopening after `EVENT_DATABASE_REBUILT` is a
|
||||
flow-authorised continuation requested by the Rebuilder, not evidence that
|
||||
AutoStart is enabled.
|
||||
|
||||
Correctness must not depend on the registration order of equal-priority resume
|
||||
handlers. P2P AutoStart records persistent room demand, while an unattended P2P
|
||||
OneShot records finite room demand. `P2PRoomSessionOwner` serialises both and
|
||||
retains the room while either demand remains. Provider-specific P2P lifecycle
|
||||
wiring and host replication scheduling may therefore run in either order.
|
||||
Focused owner tests cover AutoStart-before-OneShot and OneShot-before-AutoStart;
|
||||
the host feature-binding test must not encode their current registration order
|
||||
as a scheduling prerequisite.
|
||||
|
||||
The CLI daemon owns its initial finite convergence before its mirror scan.
|
||||
Restored settings mark that convergence as satisfied for the current lifecycle
|
||||
generation, so `syncOnStart` does not repeat it. In `--interval` mode, the
|
||||
daemon poller is the sole recurring remote-poll scheduler. In changes-feed mode,
|
||||
the scheduling functions start one configured Continuous session when
|
||||
supported; otherwise they may enable the configured generic periodic timer.
|
||||
Continuous has precedence when both are configured.
|
||||
|
||||
The resume function starts work synchronously far enough to reserve
|
||||
Continuous ownership, then lets the lifecycle handler settle without awaiting
|
||||
network completion. Concurrent resume notifications share one internal
|
||||
operation. Periodic reconciliation therefore observes the reservation before
|
||||
it can enable a competing timer. A failed operation is logged and releases the
|
||||
coalescing slot so a later resume can retry.
|
||||
|
||||
Coalescing applies only within one observed lifecycle generation. If the
|
||||
application suspends and resumes while an earlier operation is still settling,
|
||||
the context retains the newer generation and runs it after the earlier
|
||||
operation releases the slot. A result from the obsolete generation cannot
|
||||
change recurring-work ownership or initiate a OneShot fallback for the newer
|
||||
generation.
|
||||
|
||||
Disabling an interval does not retract a callback which the runtime has already
|
||||
queued. Each Periodic callback therefore rechecks lifecycle eligibility,
|
||||
readiness, suspension, configuration, external-poller ownership, and
|
||||
Continuous ownership immediately before it requests replication.
|
||||
|
||||
### Use a fixed current-provider definition
|
||||
|
||||
Commonlib defines the canonical current remote kinds, provider contract,
|
||||
capability catalogue, support-decision type, and typed provider builder. Each
|
||||
host composition explicitly declares the provider kinds which it includes and
|
||||
supplies an exhaustive definition table for that set. The current catalogue is
|
||||
CouchDB, Object Storage, and P2P; it is not a public third-party registration
|
||||
API.
|
||||
|
||||
CouchDB is part of every current host composition. Object Storage and P2P are
|
||||
compile-time composition choices and may be included or omitted without
|
||||
changing the generic scheduling feature. Adding another current provider
|
||||
requires a Commonlib kind and support declaration, host composition,
|
||||
Setup/profile schema handling, and provider-specific tests. It does not require
|
||||
a runtime plug-in registry or behaviour for unknown provider kinds.
|
||||
|
||||
Each provider definition supplies:
|
||||
|
||||
- canonical kind and diagnostic name;
|
||||
- active Replicator construction;
|
||||
- a configuration predicate and private configuration identity;
|
||||
- explicit user-initiated and unattended OneShot runners;
|
||||
- readiness requirements, an explicit Continuous support decision, and a
|
||||
transfer-stop runner;
|
||||
- the exhaustive current remote-resource catalogue; and
|
||||
- an optional cohesive central-remote administration runner.
|
||||
|
||||
The host composes CouchDB and Object Storage definitions directly. A module is
|
||||
retained only when it owns separate state or behaviour; a factory-registration-
|
||||
only module instance is not required. The stateful P2P service is composed
|
||||
independently and may be an adjunct beside a different selected main provider.
|
||||
|
||||
The definition table uses `satisfies` against required record keys. Adding a
|
||||
current provider or catalogue entry without a support decision is a compile-time
|
||||
error. A typed builder correlates each `supported` decision with its required
|
||||
role and rejects a role for an absent capability.
|
||||
|
||||
Support has three stable states:
|
||||
|
||||
```typescript
|
||||
type CapabilitySupport =
|
||||
| { readonly kind: "supported" }
|
||||
| { readonly kind: "not-implemented"; readonly reason: CapabilityReason }
|
||||
| { readonly kind: "not-applicable"; readonly reason: CapabilityReason };
|
||||
```
|
||||
|
||||
`not-implemented` means that the provider model exists but the current
|
||||
implementation does not supply it. `not-applicable` means that the model does
|
||||
not exist for that provider, such as central database locking for P2P. Reasons
|
||||
are stable codes, not arbitrary user-facing strings.
|
||||
|
||||
Historical empty `remoteType` is resolved explicitly as CouchDB at the
|
||||
persistence/profile boundary. Capability selection then uses that canonical
|
||||
kind; it never infers CouchDB by truthiness or by a negative test such as
|
||||
'neither Object Storage nor P2P'.
|
||||
|
||||
The private configuration identity covers every effective setting which binds
|
||||
the active adapter. The active publication is retained only while both the
|
||||
provider and identity are unchanged. Every changed identity follows the same
|
||||
serialised replacement transition; there is no same-instance rebind branch.
|
||||
|
||||
### Keep the active contract small and compose the differing roles
|
||||
|
||||
The active object implements only the lifecycle and transport primitives which
|
||||
are real for CouchDB, Object Storage Journal, and P2P:
|
||||
|
||||
```typescript
|
||||
interface ReplicatorInstance {
|
||||
initializeDatabaseForReplication(): Promise<boolean>;
|
||||
openReplication(
|
||||
setting: RemoteDBSettings,
|
||||
keepAlive: boolean,
|
||||
showResult: boolean,
|
||||
ignoreCleanLock: boolean
|
||||
): Promise<void | boolean>;
|
||||
terminateSync(): void | Promise<void>;
|
||||
closeReplication(): void | Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
Provider runners compose the differences in interaction authority, readiness,
|
||||
typed settlement, explicit Continuous support or inapplicability, and P2P room
|
||||
demand. Short-lived connection, preferred-tweak, Security Seed, and
|
||||
synchronisation-information operations remain caller-owned resources. Central
|
||||
administration remains one optional cohesive runner. None of those differences
|
||||
widens `ReplicatorInstance`.
|
||||
|
||||
The LiveSync central-remote administration composition shares only local-identity
|
||||
preparation, mutation ordering, milestone interpretation, and result
|
||||
settlement. Its CouchDB and Object Storage adapters retain their own milestone
|
||||
readers and connection or client ownership. The fixed provider definition has
|
||||
already selected the adapter, so those readers validate only the additional
|
||||
operations which they use; they do not rediscover capability support through a
|
||||
concrete-class `instanceof` test.
|
||||
|
||||
The central OneShot adapters likewise require only the local structural
|
||||
`openOneShotReplicationWithOutcome()` operation. Concrete constructors remain
|
||||
at the host-composition boundary, but constructor identity is not a capability
|
||||
test. A structurally incomplete active instance settles as a failed outcome
|
||||
rather than falling back to the legacy `openReplication()` operation.
|
||||
|
||||
Directional Fetch and Rebuild, Streaming Fetch, CouchDB on-demand Chunk reads,
|
||||
remote-size inspection, compromised-Chunk inspection, Garbage Collection,
|
||||
compaction, and journal checkpoint maintenance are workflow or
|
||||
provider-specific concerns. They do not become exhaustive provider
|
||||
capabilities merely because an application flow branches by topology.
|
||||
|
||||
Local node identity initialisation remains at the established Replicator and
|
||||
local-database initialisation boundary for this change. A later physical
|
||||
database-lifetime review may move it only after establishing a concrete owner
|
||||
and migration benefit. Replication statistics remain a `ReplicatorService`
|
||||
telemetry sink.
|
||||
|
||||
### Make unattended work explicit and truthful
|
||||
|
||||
User-initiated and unattended OneShot Sync are separate roles. Interaction
|
||||
authority is an upper bound on local interaction, not an instruction to display
|
||||
a dialogue. An operation may apply a stricter veto, but cannot request an
|
||||
interaction which its caller did not permit. Remote refusal and incoming-peer
|
||||
consent remain independent decisions.
|
||||
|
||||
```typescript
|
||||
type UnattendedTrigger = "resume" | "periodic" | "database-event" | "editor-save" | "file-open" | "merge" | "daemon";
|
||||
|
||||
interface InteractionPermissions {
|
||||
readonly peerSelection: boolean;
|
||||
readonly localPeerAdmission: boolean;
|
||||
readonly configurationExchange: boolean;
|
||||
readonly failureRecovery: boolean;
|
||||
}
|
||||
|
||||
type PermittedInteractionPermissions =
|
||||
| (InteractionPermissions & { readonly peerSelection: true })
|
||||
| (InteractionPermissions & { readonly localPeerAdmission: true })
|
||||
| (InteractionPermissions & { readonly configurationExchange: true })
|
||||
| (InteractionPermissions & { readonly failureRecovery: true });
|
||||
|
||||
type InteractionAuthority =
|
||||
| typeof NO_INTERACTION
|
||||
| { readonly kind: "permitted"; readonly permissions: PermittedInteractionPermissions };
|
||||
|
||||
const NO_INTERACTION = { kind: "forbidden" } as const;
|
||||
|
||||
interface UserInitiatedOneShotRequest {
|
||||
readonly trigger: "manual";
|
||||
readonly interaction: InteractionAuthority;
|
||||
}
|
||||
|
||||
interface UserInitiatedOneShot {
|
||||
run(request: UserInitiatedOneShotRequest): Promise<ReplicationOutcome>;
|
||||
}
|
||||
|
||||
interface UnattendedOneShot {
|
||||
run(request: {
|
||||
readonly trigger: UnattendedTrigger;
|
||||
readonly interaction: typeof NO_INTERACTION;
|
||||
}): Promise<ReplicationOutcome>;
|
||||
}
|
||||
```
|
||||
|
||||
`NO_INTERACTION` is the only all-false authority. Shared immutable authority
|
||||
values are reused by hosts, avoiding a permission allocation per operation.
|
||||
Operation-specific requests, including P2P configuration exchange, carry the
|
||||
same upper bound and expose only relevant permissions. An unattended path may
|
||||
use persisted or automatic acceptance policy, but cannot obtain local
|
||||
interaction authority implicitly.
|
||||
|
||||
The same authority bounds presentation. An unattended P2P path may retain an
|
||||
informational diagnostic, but no-target, authentication, tweak-mismatch, and
|
||||
overlapping-transfer settlements must not promote themselves to a Notice.
|
||||
User-initiated paths retain their existing Notice-level presentation. This is
|
||||
a caller-authority rule, not a new process-wide presentation framework.
|
||||
|
||||
Outcomes do not collapse failure into `void` or `boolean`:
|
||||
|
||||
```typescript
|
||||
const REPLICATION_COMPLETED = { status: "completed" } as const;
|
||||
const REPLICATION_CANCELLED = { status: "cancelled" } as const;
|
||||
|
||||
type ReplicationOutcome =
|
||||
| typeof REPLICATION_COMPLETED
|
||||
| typeof REPLICATION_CANCELLED
|
||||
| { readonly status: "blocked"; readonly reason: ReplicationBlockReason }
|
||||
| { readonly status: "partial"; readonly detail: PartialReplicationDetail }
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly error: unknown;
|
||||
readonly recoveryHint?: CentralCompatibilityRecoveryHint;
|
||||
};
|
||||
```
|
||||
|
||||
Completed and cancelled values are shared singletons or literals. Blocked,
|
||||
partial, and failed results may carry diagnostic detail. Central provider
|
||||
initialisation, reset, lock, unlock, and resolution settle only after their
|
||||
defined remote write succeeds; a Rebuilder must not continue after an ignored
|
||||
mutation failure.
|
||||
|
||||
CouchDB and Object Storage Journal record one immutable central-compatibility
|
||||
decision inside the finite attempt which owns the connection or borrowed
|
||||
client. Only a rejection from that exact attempt becomes a recovery hint. A
|
||||
transport failure before assessment, or after an accepted assessment, cannot
|
||||
reuse mutable mismatch or lock state from an earlier attempt. P2P produces no
|
||||
central-compatibility decision. The recovery field is already specific to this
|
||||
contract, so its value carries the stable rejection reason and any preferred
|
||||
tweak value without a redundant kind discriminator.
|
||||
|
||||
`cancelled` means that the requested finite operation did not reach its normal
|
||||
completion boundary. It does not promise rollback. A provider may retain
|
||||
documents and checkpoints from batches which had already settled before the
|
||||
cancellation signal was observed, and a later operation resumes from that
|
||||
durable state.
|
||||
|
||||
### Preserve uncertainty at the boundary which owns the decision
|
||||
|
||||
Capability availability and operation results are separate. A supported
|
||||
operation may fail, while an inapplicable operation must not be called or
|
||||
reported as a network attempt. A workflow which legitimately has no step for
|
||||
its topology may complete that branch without claiming that a provider ran an
|
||||
operation.
|
||||
|
||||
Keep uncertainty where it changes a safety decision. In particular, an Object
|
||||
Storage read distinguishes `available`, `not-found`, and `unavailable` before a
|
||||
caller decides whether creation is permitted. Only explicit `not-found` may
|
||||
create Journal synchronisation parameters; an unavailable read cannot be
|
||||
converted to a missing value or a new Security Seed.
|
||||
|
||||
The same rule applies to central milestone mutation and verification. A
|
||||
milestone mutation may merge an available document or initialise one after an
|
||||
explicit `not-found` result. An unavailable read rejects before upload. A
|
||||
postcondition reader reports that unavailability as a read failure with its
|
||||
diagnostic detail; it does not report that the milestone is missing.
|
||||
|
||||
This principle does not require one generic `RemoteObservation<T>` type or an
|
||||
active-read catalogue. Existing provider-specific inspection and maintenance
|
||||
methods may retain their current compatibility surface until their real
|
||||
consumers are migrated. When a later bounded migration needs to distinguish an
|
||||
observed zero or empty result from an unavailable inspection, that consumer
|
||||
owns the smallest explicit result type required by the decision.
|
||||
|
||||
### Give active ownership and probes explicit boundaries
|
||||
|
||||
`ReplicatorService` is the sole owner of the active Replicator. Replacement and
|
||||
disposal use one explicit quiescing transition under its transition lock:
|
||||
|
||||
```text
|
||||
active -> quiescing -> closed -> replacement published
|
||||
```
|
||||
|
||||
The transition removes the old publication from `current`, rejects later
|
||||
admission, requests the provider's supported transfer cancellation, and drains
|
||||
work which was already admitted. Only then does it close the old Replicator and
|
||||
publish another active context. Acquisitions ordered after the
|
||||
transition receive only the replacement. A cancellation failure does not
|
||||
permit the physical close boundary to be skipped. If admitted work cannot
|
||||
settle, no replacement is published. If physical close fails, the quiescing
|
||||
publication remains fenced so a later transition can retry that close before
|
||||
constructing a replacement.
|
||||
|
||||
The publication object itself is the private generation identity; no separate
|
||||
generation number or public lease is required. Its reservation count is not the
|
||||
service-wide bounded-activity count or finite-replication count: those counts
|
||||
remain status and quiescence signals and can include trials, local work, and an
|
||||
outer Rebuilder flow which itself initiates a lifecycle transition. A switch
|
||||
which waited for either global count could therefore wait for the operation
|
||||
which is awaiting that same switch.
|
||||
|
||||
The active context atomically carries the provider, Replicator, and private
|
||||
configuration identity. A settings-bearing operation captures one effective
|
||||
settings snapshot, then reprojects and compares its identity inside admission
|
||||
immediately before provider dispatch. A mismatch settles without combining a
|
||||
new setting with an earlier Replicator. An explicit stop request acts on the
|
||||
exact active owner and therefore does not require a settings comparison.
|
||||
|
||||
New typed production work cannot synchronously inspect an unreserved active
|
||||
context. It must acquire the context or run inside the admitted callback
|
||||
boundary. The synchronous `inspectActiveReplicatorContext()` view is protected
|
||||
and exists only for lifecycle diagnostics and focused tests.
|
||||
|
||||
The public `getActiveReplicator()` remains temporarily for named compatibility
|
||||
consumers and retains its established missing-active diagnostic. It is not a
|
||||
typed ownership path. A separate side-effect-free `hasActiveReplicator()`
|
||||
predicate may distinguish a compatibility Replicator whose provider was not
|
||||
composed from complete absence. It returns neither the Replicator nor its
|
||||
context, and cannot be used to dispatch work.
|
||||
|
||||
The minimum consumer surface is a callback boundary,
|
||||
`runWithActiveReplicatorContext(callback)`, rather than an exposed lease or
|
||||
release token. Admission is ordered with lifecycle transitions, the callback
|
||||
receives one exact context, and private release runs in `finally` without
|
||||
entering the transition queue.
|
||||
|
||||
The callback must not initiate or await settings realisation, database reset or
|
||||
replacement, active Replicator retirement, or another operation which queues
|
||||
the same lifecycle transition. Such recovery or reconfiguration is staged
|
||||
after the reserved dispatch settles. A process-wide re-entrancy flag would
|
||||
both reject unrelated asynchronous work and miss re-entry after an `await`, so
|
||||
the contract is documented and tested at the owning workflows rather than
|
||||
claimed as a reliable runtime detector.
|
||||
|
||||
Failure presentation and recovery start only after the finite reservation has
|
||||
settled. A later remote mutation re-enters through the callback boundary and
|
||||
requires reference equality with the failed context. It cannot apply the
|
||||
decision produced by one publication to its replacement.
|
||||
|
||||
An edited-settings trial is different: it owns an independent Replicator and
|
||||
connection, never borrows the active publication, and disposes both resources.
|
||||
An owned Security Seed resource also forces a fresh provider read for its
|
||||
settings snapshot. Reusing a process-cached synchronisation parameter would
|
||||
turn an observation made for an earlier flow into current trial evidence.
|
||||
|
||||
Application suspension is a reversible host pause, not an ownership
|
||||
transition. `ReplicatorService` orders the provider's transfer-stop request but
|
||||
retains the active publication, accepts later work, and does not drain or close
|
||||
the Replicator. Provider-specific transport lifecycle, including P2P room and
|
||||
relay handling, remains independently owned.
|
||||
|
||||
Plug-in unload is terminal and reuses the same quiescing retirement as disposal;
|
||||
it does not add another public state or unload capability. The lifecycle handler
|
||||
fences admission, requests transfer cancellation, drains admitted work, and
|
||||
closes the Replicator before `ControlService` closes the local database. This
|
||||
ordering matters even when disabling the plug-in leaves the JavaScript process
|
||||
alive.
|
||||
|
||||
The generic stop role is an idempotent request to stop a provider transfer after
|
||||
transport work begins. It does not promise cancellation of readiness checks,
|
||||
Security Seed acquisition, or external storage calls which do not consume a
|
||||
cancellation signal. P2P implements this role through its room-session owner:
|
||||
the request aborts the current finite-operation scopes without closing the room
|
||||
or disabling later transfers. Its RPC request, incoming `reqSync`, and
|
||||
replication batch loop consume the same effective signal. An already-started
|
||||
atomic database operation may settle before cancellation completes, but no new
|
||||
batch is started afterwards.
|
||||
|
||||
A Journal connectivity preflight is not itself cancelled by this role. The
|
||||
Replicator instead records a private Stop generation when that preflight begins
|
||||
and checks it again before entering `sync()`, `sendLocalJournal()`, or
|
||||
`receiveRemoteJournal()`. A Stop admitted while the preflight is pending can
|
||||
therefore wait for the attempt to settle without allowing a new client transfer
|
||||
to start afterwards.
|
||||
|
||||
The bounded Continuous startup call and an explicit stop request are admitted
|
||||
against their exact publication. Continuous admission ends when the provider
|
||||
has registered ownership and settled startup; it is never retained for the
|
||||
lifetime of the long-lived task. Directional transfer and central-remote
|
||||
administration stop the admitted publication's active transfer before their
|
||||
exclusive operation begins. An unavailable or failed stop prevents that
|
||||
operation rather than allowing transfer and mutation to overlap.
|
||||
|
||||
`getNewReplicator()` is not a general temporary-instance API. CouchDB and
|
||||
Object Storage Setup and settings flows request narrow connection or
|
||||
preferred-tweak probes. A resource-returning factory returns an owned resource
|
||||
with idempotent asynchronous `dispose()`. Trial settings are passed to the
|
||||
probe itself and cannot silently read active settings. P2P Setup instead uses
|
||||
the stable service's connection-probe admission described in Part 2. Only its
|
||||
idle continuation constructs and disposes a short-lived raw signalling trial.
|
||||
Neither form can replace the active Replicator or the P2P service.
|
||||
|
||||
The CouchDB synchronisation-information resource resolves `false` only when it
|
||||
observes incompatible synchronisation information. Connection, setup, and
|
||||
verification failures reject so a settings caller can report operational
|
||||
failure separately from incompatibility. Connection-probe result presentation
|
||||
is likewise explicit: `showResult` retains the established CouchDB success or
|
||||
failure Notice, while an ordinary silent probe emits neither result Notice.
|
||||
|
||||
Streaming Fetch receives an owned Security Seed resource bound to its settings
|
||||
snapshot. The current compatibility implementation may construct an
|
||||
unpublished Replicator internally, but the resource owns and disposes it and
|
||||
cannot replace the active publication.
|
||||
|
||||
### Keep initialisation workflows explicit
|
||||
|
||||
- Fetch, Rebuild, overwrite, and first-device setup remain application
|
||||
workflows. Their direction, reset, lock, peer selection, local-database work,
|
||||
and convergence passes are not one Replicator capability.
|
||||
- CouchDB and Object Storage use their established workflow-local directional
|
||||
adapters and central administration where required.
|
||||
- A P2P first device prepares only its local state. An additional P2P device
|
||||
selects a peer and uses the real finite download path; P2P does not emulate a
|
||||
central reset, lock, milestone, or upload.
|
||||
- CouchDB Streaming Fetch remains a separate initial-transfer service and uses
|
||||
only its owned Security Seed dependency.
|
||||
|
||||
The `EVENT_DATABASE_REBUILT` continuation remains separately authorised and
|
||||
does not imply `syncOnStart` or P2P AutoStart. Complete Rebuilder and
|
||||
maintenance-facade migration is a later bounded change rather than a condition
|
||||
for the active Replicator core.
|
||||
|
||||
## Target capability matrix for current providers
|
||||
|
||||
`S` means supported, `NI` means not implemented, and `NA` means not applicable.
|
||||
Configuration and reachability are request preconditions or outcomes, not
|
||||
support states.
|
||||
|
||||
| Active provider role | CouchDB | Object Storage | P2P |
|
||||
| -------------------------------------- | ------- | -------------- | --- |
|
||||
| User-initiated OneShot Sync | S | S | S |
|
||||
| Unattended OneShot Sync without UI | S | S | S |
|
||||
| Ordinary long-lived Continuous session | S | NA | NA |
|
||||
| Request to stop active transfer | S | S | S |
|
||||
|
||||
Every provider definition records the Continuous row explicitly. CouchDB
|
||||
supplies its runner, while Object Storage and P2P declare the role not
|
||||
applicable; omission is not a fourth support state.
|
||||
|
||||
The current definition also declares the finite resources and the one optional
|
||||
central facility which have real consumers:
|
||||
|
||||
| Provider-owned facility | CouchDB | Object Storage | P2P |
|
||||
| --------------------------------------------- | ------- | -------------- | ------ |
|
||||
| Connection probe | S | S | NA |
|
||||
| Preferred-tweak probe | S | S | NA |
|
||||
| Security Seed resource | S | S | NA |
|
||||
| Synchronisation-information resource | S | NA | NA |
|
||||
| Cohesive central-remote administration runner | S | S | absent |
|
||||
|
||||
`remoteResources` is exhaustive over its four stable machine keys, so adding a
|
||||
resource requires an explicit decision from every composed provider. The
|
||||
central-remote administration field is optional because there is no corresponding
|
||||
P2P facility. Actions within that runner are the current central protocol, not
|
||||
an exhaustive capability table imposed on every Replicator.
|
||||
|
||||
The public contract is named `CentralRemoteAdministration*` because every
|
||||
current action, observation, failure, and postcondition belongs to that central
|
||||
milestone protocol. Established CLI command names remain unchanged.
|
||||
|
||||
The P2P Setup signalling check is not the P2P entry in the provider-owned
|
||||
connection-probe row. P2P has no central connection resource; its
|
||||
`P2PConnectionProbeAdmission` is a focused view of the independently composed
|
||||
P2P service. It compares requested relays with the binding held by the existing
|
||||
room-session owner as specified in Part 2; it adds neither a process-global
|
||||
lease nor a second owner.
|
||||
|
||||
The following concerns deliberately stay outside this capability matrix:
|
||||
|
||||
| Concern | Current owner |
|
||||
| ---------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Directional Fetch and Rebuild | Application workflow over provider-specific adapters |
|
||||
| Streaming Fetch | CouchDB initial-transfer workflow plus an owned Security Seed resource |
|
||||
| On-demand remote Chunks and compromised-Chunk inspection | CouchDB compatibility or maintenance consumers |
|
||||
| Remote size, Garbage Collection, compaction, and device registry | Provider-specific inspection and maintenance flows |
|
||||
| P2P room, relay, peer selection, admission, watch, and broadcast | Stable P2P service and room-session owner in Part 2 |
|
||||
|
||||
P2P unattended OneShot means a role exists which uses configured target names
|
||||
without opening a dialogue. Peer-room, watch, acceptance, and broadcast roles
|
||||
are provider-specific facets, not the ordinary Continuous role; their trigger
|
||||
matrix and de-duplication rules are owned by Part 2.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Move the resume handler and retain a `remoteType` switch
|
||||
|
||||
This would fix issue 1140 narrowly, but would leave future providers and
|
||||
triggers subject to the same omission. It would not stop automatic P2P calls
|
||||
from reaching an interactive method, and capability semantics would remain
|
||||
implicit.
|
||||
|
||||
### Use only optional methods or Boolean support flags
|
||||
|
||||
Optional runtime roles are useful, but they do not force a support decision when
|
||||
a provider or catalogue entry is added. Exhaustive support metadata plus a
|
||||
typed builder keeps runtime interfaces small while requiring the decision at
|
||||
compile time.
|
||||
|
||||
### Use only a provider-discriminated union
|
||||
|
||||
A provider union is appropriate for provider-specific maintenance after
|
||||
explicit narrowing. It cannot model adjunct P2P beside another selected main
|
||||
provider and is not a generic feature test.
|
||||
|
||||
### Tag every value and hot-path return
|
||||
|
||||
Uniform wrappers would add allocation and noise to Chunk, document,
|
||||
changes-feed, and queue paths without improving semantics where an identity is
|
||||
safe. Tags remain for low-frequency observations and outcomes which affect
|
||||
control flow.
|
||||
|
||||
### Retain `getNewReplicator()` as the trial and command factory
|
||||
|
||||
The handler is order-dependent, can suppress construction errors, and can
|
||||
replace a feature-owned P2P transport. Flow-specific probes and the stable P2P
|
||||
service make ownership explicit.
|
||||
|
||||
### Treat neutral compatibility results as supported operations
|
||||
|
||||
Dummy zero counts, empty Security Seeds, false values which collapse an
|
||||
operational failure into incompatibility or absence, and silent mutations lose
|
||||
distinctions required for safety and recovery. A neutral value remains only
|
||||
when every caller proves it to be the operation's identity.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `syncOnStart` becomes an application policy for every unattended finite
|
||||
provider which supports it, rather than a CouchDB module behaviour.
|
||||
- Adding a current provider or capability requires an explicit compile-time
|
||||
support decision.
|
||||
- Unsupported central operations are no longer represented as successful P2P
|
||||
no-ops or transport errors.
|
||||
- Safe identities remain simple, while safety-sensitive observations are
|
||||
explicit.
|
||||
- Setup and trial configuration cannot replace an active or adjunct transport.
|
||||
- Central mutations and integrity checks cannot silently turn failure or
|
||||
unavailability into success.
|
||||
- P2P remains first-class without being mislabelled as central Continuous
|
||||
replication.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not introduce third-party remote-provider registration.
|
||||
- Do not redesign every replication result or user-facing message in one
|
||||
change.
|
||||
- Do not make Streaming Fetch a generic Replicator capability.
|
||||
- Do not make P2P pretend to own a central remote database.
|
||||
- Do not infer support from `remoteType`, constructor identity, a falsy result,
|
||||
or a neutral value.
|
||||
- Do not redefine `syncOnStart` as a once-per-process setting.
|
||||
- Do not change established profile persistence or Setup flag-file restart
|
||||
ordering as part of this capability split.
|
||||
|
||||
## References
|
||||
|
||||
- [Part 2: P2P service and session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
|
||||
- [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md)
|
||||
- [Bounded Remote Activity](2026_07_bounded_remote_activity.md)
|
||||
- [Make Onboarding Profile-Aware](2026_07_multiple_remote_onboarding.md)
|
||||
- [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
|
||||
- [P2P Transport Compatibility Controls](2026_08_p2p_transport_compatibility.md)
|
||||
- [CouchDB Remote Connection Ownership](2026_08_couchdb_remote_connection_ownership.md)
|
||||
- [Package the Common Library Behind Explicit Host Boundaries](2026_07_common_library_package_boundary.md)
|
||||
- [Self-hosted LiveSync issue 1140](https://github.com/vrtmrz/obsidian-livesync/issues/1140)
|
||||
- [Self-hosted LiveSync issue 1147](https://github.com/vrtmrz/obsidian-livesync/issues/1147)
|
||||
@@ -0,0 +1,404 @@
|
||||
---
|
||||
date: 2026-08-27
|
||||
commonlib-version: "0.1.20"
|
||||
self-hosted-livesync-version: "1.0.21"
|
||||
status: proposed
|
||||
series: replicator-capabilities-and-lifecycle
|
||||
part: 2 of 3
|
||||
---
|
||||
|
||||
# Architectural Decision Record: Replicator Capabilities and Lifecycle Orchestration — Part 2: P2P Service and Session Lifecycle
|
||||
|
||||
Series navigation: this is Part 2 of 3. Read [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md)
|
||||
first, then continue with [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md).
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. This record defines the P2P service owner, room-session boundary,
|
||||
narrow contract views, automation demands, replacement fencing, and trigger
|
||||
semantics. Generic provider and capability rules are owned by Part 1; the
|
||||
implementation and verification order is owned by Part 3.
|
||||
|
||||
The implemented state is recorded separately in Commonlib's
|
||||
`docs/p2p-transport-lifecycle.md` design document. It supersedes the
|
||||
replaceable LiveSync P2P Replicator and current-result ownership described by
|
||||
the accepted [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
|
||||
record. The accepted record's decisions about serialised room operations,
|
||||
`room.leave()`, Trystero-owned physical peers, and relay reconnection remain in
|
||||
force. This ADR remains the decision and migration target; the Commonlib
|
||||
design document records the names and ownership boundaries which actually
|
||||
landed.
|
||||
|
||||
## Scope and context
|
||||
|
||||
The Commonlib P2P feature currently spans `TrysteroReplicatorP2PServer`,
|
||||
`TrysteroReplicator`, and the LiveSync-specific `LiveSyncTrysteroReplicator`.
|
||||
The current result resolves a replaceable Replicator while the room, signalling,
|
||||
watch, broadcast, diagnostics, platform-event subscriptions, and database
|
||||
feeds have longer-lived relationships. Installing that replaceable object as
|
||||
the active Replicator makes ordinary active-handle disposal capable of closing
|
||||
an adjunct or policy-owned room.
|
||||
|
||||
The same room membership serves several independent behaviours:
|
||||
|
||||
- P2P AutoStart opens the room and signalling service;
|
||||
- AutoSync reacts to an advertised and accepted peer with one finite transfer;
|
||||
- AutoWatch follows later changes broadcast by a selected peer;
|
||||
- AutoBroadcast publishes local database changes;
|
||||
- explicit commands pull, push, or synchronise against a selected peer;
|
||||
- incoming `reqSync` requests pull from an accepted peer; and
|
||||
- diagnostics and platform events observe the transport.
|
||||
|
||||
These behaviours must share one room owner, but they must not share one
|
||||
implicit policy. A setting change may replace the room session while another
|
||||
provider remains the selected main remote. A setup probe must not close that
|
||||
session. A finite operation may need a room even when AutoStart is disabled.
|
||||
|
||||
## Decision
|
||||
|
||||
### One stable P2P service owns each room session
|
||||
|
||||
The host composes one stable P2P service independently of the selected main
|
||||
provider. It may be present as an adjunct beside CouchDB or Object Storage, or
|
||||
its non-owning adapter may serve as the selected main P2P Replicator. The host
|
||||
owns the service lifetime; the service owns all LiveSync-specific room
|
||||
resources.
|
||||
|
||||
A **P2P room session** is one active room membership and every resource whose
|
||||
validity depends on that membership:
|
||||
|
||||
- the Trystero room, RPC actions, and `RpcRoom`;
|
||||
- its internal session epoch, session controller, and finite-operation
|
||||
registry;
|
||||
- advertisement state and temporary peer decisions;
|
||||
- peer-bound RPC clients and remote database proxies;
|
||||
- connection, diagnostic, and platform-event subscriptions; and
|
||||
- RPC publication, the watch set, database and broadcast change feeds, and
|
||||
in-flight finite-transfer de-duplication bound to the local database.
|
||||
|
||||
A **session epoch** is the internal identity and fence of one room-session
|
||||
object. It is not a public capability, a persisted profile identifier, or a
|
||||
synonym for the logical room. Peer callbacks and finite-operation tokens carry
|
||||
that identity and cannot be routed into a replacement session.
|
||||
|
||||
Closing or replacing a session fences its epoch, stops new operations, settles
|
||||
or fails in-flight work, closes RPC and client resources, and leaves the room
|
||||
in the transport-owned order. Persisted peer acceptance decisions survive;
|
||||
temporary decisions, advertisements, clients, listeners, and feeds do not.
|
||||
The underlying WebRTC peer remains under Trystero's shared-peer ownership as
|
||||
specified by the accepted lifecycle record.
|
||||
|
||||
### Expose narrow contract views
|
||||
|
||||
The service does not expose a room session, raw host, or concrete Replicator to
|
||||
ordinary consumers. It supplies these views over the same owner:
|
||||
|
||||
1. `P2PTransportLifecycle` observes room state and accepts explicit,
|
||||
user-owned connect or disconnect requests.
|
||||
2. `P2PConnectionProbeAdmission` arbitrates a complete Setup signalling check
|
||||
against the current relay binding without exposing or replacing the room.
|
||||
3. `P2PPeerDirectory` supplies peer snapshots and peer arrival or departure.
|
||||
4. `P2PPeerAdmission` evaluates incoming peers and administers temporary or
|
||||
persisted acceptance decisions.
|
||||
5. `P2PTargetedTransfer` performs pull, requested push, and bidirectional
|
||||
finite synchronisation against an explicit peer, and executes the persisted
|
||||
configured-target set without interactive peer selection.
|
||||
6. `P2PChangeRelay` administers peer watch and local-change broadcast.
|
||||
7. `P2PConfigurationExchange` performs peer configuration exchange under its
|
||||
declared interaction authority.
|
||||
8. `P2PDiagnostics` supplies status and RTC diagnostics without exposing raw
|
||||
room or peer connections.
|
||||
|
||||
These are stable service-level contract views, not independent wrapper or state
|
||||
owners. One implementation may satisfy several views.
|
||||
Advertisement and admission state remain under one peer-access owner, while
|
||||
pull, push, and bidirectional transfer remain under one transfer owner. A
|
||||
consumer which needs more than one view receives those views explicitly; it
|
||||
does not receive a general P2P context or service locator.
|
||||
|
||||
The views resolve the current published session at invocation. Finite transfer
|
||||
work which carries an old epoch is rejected or cancelled rather than dispatched
|
||||
into a replacement. A configuration or diagnostic request which was already
|
||||
admitted may settle against its originating session, while persisted peer
|
||||
admission decisions intentionally settle independently of room replacement.
|
||||
These operations are outside active-transfer cancellation and cannot publish
|
||||
the former session's peer callbacks or status into its replacement. The epoch
|
||||
remains internal. The active P2P Replicator is a non-owning adapter over the
|
||||
views, and its disposal cannot implicitly leave the service-owned room.
|
||||
|
||||
### Make explicit disconnect a veto, not another policy demand
|
||||
|
||||
`P2PTransportLifecycle` distinguishes user intent from automation:
|
||||
|
||||
- explicit connect resumes relay reconnection and establishes a user-owned
|
||||
room demand;
|
||||
- explicit disconnect is the sole force-close path, retires the current
|
||||
session, invalidates all demands under the retirement contract, pauses relay
|
||||
reconnection, and establishes a service-lifetime veto against AutoStart; and
|
||||
- only a later explicit connect clears that veto.
|
||||
|
||||
Automated policies and finite operations acquire or release only their own
|
||||
demands. They cannot close a room held by another demand and cannot override an
|
||||
explicit disconnect veto. This is a lifecycle veto, not the opposite of
|
||||
`InteractionAuthority`: local interaction authority is the upper bound used by
|
||||
operations, as specified in Part 1. An operation may impose a stricter veto,
|
||||
but an unattended operation cannot gain permission to open a dialogue.
|
||||
|
||||
`EVENT_DATABASE_REBUILT` is a separately authorised continuation of the owning
|
||||
Rebuilder workflow, not an AutoStart demand. After the replacement database is
|
||||
ready, that continuation may request a room independently of the AutoStart
|
||||
veto. It does not clear the veto for later automatic-start events.
|
||||
|
||||
Host lifecycle closure has a second, private, reversible state. The host sets
|
||||
that state before it cancels delayed automation and closes the room. While it
|
||||
is set, settings reconciliation and finite stable-view operations cannot add a
|
||||
room demand. Only explicit connect, database-rebuild continuation, or the
|
||||
AutoStart schedule established by a resumed host lifecycle clears it; merely
|
||||
reconciling saved settings does not.
|
||||
|
||||
This state does not replace or clear the explicit-disconnect veto. Explicit
|
||||
connect clears both states, resumed AutoStart still observes the user's veto,
|
||||
and Rebuild remains the separately authorised continuation described above.
|
||||
The distinction is private service policy, not another public capability or
|
||||
room owner.
|
||||
|
||||
### Separate automation policy from room ownership
|
||||
|
||||
P2P automation is a composed service feature. It owns `P2P_AutoStart`,
|
||||
AutoSync, AutoWatch, and AutoBroadcast policy, including delayed work and
|
||||
automatic-trigger coalescing. It consumes the transport, peer, admission,
|
||||
transfer, and change-relay views but does not own their mutable state.
|
||||
|
||||
`P2PChangeRelay` owns the actual watch set and database changes feed.
|
||||
`P2PTargetedTransfer` owns explicit finite-transfer and configured-target
|
||||
execution. The stable automation coordinator owns baseline de-duplication
|
||||
shared by AutoSync and configured-target requests. Incoming-peer consent is
|
||||
distinct from a local caller's authority to select a peer or open a dialogue.
|
||||
Persisted or automatic acceptance may authorise an unattended path; it cannot
|
||||
create local interaction authority implicitly.
|
||||
|
||||
Every finite operation which needs a room obtains an internal demand from the
|
||||
room-session owner. Once a session admits the operation, that session owns its
|
||||
operation controller and settlement. The operation consumes an effective
|
||||
signal composed from the room session, its operation controller, and any
|
||||
narrower caller or incoming-RPC signal. Neither the adapter nor the UI consumer
|
||||
owns this bookkeeping.
|
||||
|
||||
Acquiring another demand does not open another session. Releasing it never
|
||||
closes a session still required by AutoStart, another finite operation, or
|
||||
another host consumer. A request to stop active transfer aborts the registered
|
||||
finite-operation controllers but does not abort the room-session controller;
|
||||
the room remains usable and later transfers obtain fresh operation controllers.
|
||||
Retiring the room session aborts its session controller, which cancels every
|
||||
remaining child operation. Demand and controller bookkeeping is internal to the
|
||||
service and is not a general consumer contract; it cannot turn a finite transfer
|
||||
into persistent transport policy.
|
||||
|
||||
Cancellation is cooperative and does not roll back durable work. Pull,
|
||||
requested push, and bidirectional synchronisation propagate the effective
|
||||
signal through the initiating RPC, incoming `reqSync`, the reverse database RPC
|
||||
calls, and the replication batch loop. An atomic PouchDB read or write which has
|
||||
already begun may settle. If a batch write has begun, the operation processes
|
||||
its successful writes and records the batch checkpoint only after every
|
||||
required revision has settled successfully. Cancellation before the write does
|
||||
not advance that checkpoint. The operation then reports a cancelled result and
|
||||
does not start another batch. Session retirement awaits that settlement before
|
||||
releasing RPC and room resources.
|
||||
|
||||
### Reconcile session settings atomically
|
||||
|
||||
The effective P2P session binding is derived from the selected profile, the
|
||||
settings which affect transport, the device identity, and the current local
|
||||
database identity. It is not a new persisted profile identifier or a
|
||||
device-local override. Automation and admission policy is reconciled on the
|
||||
current room. The service reconciles the binding independently of the selected
|
||||
main provider, so an adjunct room can be replaced while CouchDB or Object
|
||||
Storage remains active.
|
||||
|
||||
A change to any binding input retires the whole room session and opens a
|
||||
replacement when policy still requires one. A profile-selection or policy-only
|
||||
change which preserves the effective binding keeps the room and reconciles its
|
||||
current policy. A real replacement preserves one atomic listener and policy
|
||||
boundary: advertisements and temporary peer decisions are reacquired, while
|
||||
persisted peer decisions survive. AutoStart reconnects when it remains enabled,
|
||||
host lifecycle closure has been resumed, and no explicit-disconnect veto is
|
||||
active. No old listener, credential, client, or policy demand remains reachable
|
||||
after replacement.
|
||||
|
||||
The candidate captures its settings, device identity, and local database object
|
||||
when it is constructed. The owner re-reads the effective binding after the room
|
||||
has opened and publishes the candidate only when it still matches. A setting or
|
||||
database change during open therefore retires the stale candidate rather than
|
||||
making it current.
|
||||
|
||||
Reconciliation is serialised with room lifecycle operations:
|
||||
|
||||
1. fence new session work;
|
||||
2. abort the old session's finite-operation scopes and await their cooperative
|
||||
settlement;
|
||||
3. close and leave the old room in transport-owned order;
|
||||
4. open and validate the candidate session; and
|
||||
5. publish the replacement only after it has opened successfully.
|
||||
|
||||
The service never exposes a partly initialised candidate or treats a cancellation
|
||||
request as rollback. If bounded settlement or candidate opening fails,
|
||||
reconciliation reports the failure and publishes no mixed old and new session.
|
||||
A candidate-open failure leaves one observable disconnected state with policy
|
||||
demands unsatisfied; it does not revive the fenced session or start an unbounded
|
||||
retry loop. A later lifecycle trigger or explicit connect may retry.
|
||||
|
||||
### Order local database replacement across both owners
|
||||
|
||||
The database lifecycle transition owns ordering above `ReplicatorService` and
|
||||
the P2P service:
|
||||
|
||||
1. fence acquisition of active Replicator and P2P room work;
|
||||
2. retire the active adapter;
|
||||
3. settle and retire P2P database-bound feeds and publication;
|
||||
4. publish the replacement local database identity only after both boundaries
|
||||
have settled;
|
||||
5. rebind or replace the active provider;
|
||||
6. reconcile P2P against the new database identity; and
|
||||
7. let the Rebuilder request its separately authorised reopen.
|
||||
|
||||
Each service serialises its own resources. The database transition owns the
|
||||
cross-service ordering rather than introducing one transport-wide lock. Reset
|
||||
preparation and explicit database close both await service-owned room
|
||||
retirement before database managers are torn down and the old physical handle
|
||||
is destroyed or closed. Explicit close settles every registered cleanup handler
|
||||
sequentially, even when an earlier cleanup fails; its aggregate result is
|
||||
diagnostic rather than a close veto.
|
||||
|
||||
### De-duplicate by logical lifecycle, not by room epoch
|
||||
|
||||
Completed AutoSync baselines are scoped by normalised peer name and application
|
||||
lifecycle generation. In-flight baseline promises are indexed by normalised
|
||||
peer name until they settle. Trigger provenance and session epoch are not part
|
||||
of either lookup, so a transport-only reconnect cannot repeat an in-flight or
|
||||
completed baseline transfer.
|
||||
|
||||
Both the in-flight baseline promise and completed baseline history belong to
|
||||
the stable automation coordinator and survive transport-only or policy-only
|
||||
session replacement. The originating room session still owns cancellation and
|
||||
settlement of the actual transfer. A lifecycle or logical-identity change clears
|
||||
completed history, but does not clear an in-flight promise. A request for the
|
||||
same normalised peer name may therefore share that promise until it settles.
|
||||
Work from an older automation generation may settle, but cannot publish
|
||||
completion into the current generation. Only completed per-peer baselines are
|
||||
recorded; partial requests record completed peers only, while blocked,
|
||||
cancelled, failed, and incomplete peers remain eligible for a later bounded
|
||||
retry.
|
||||
|
||||
The automation owner clears settled records when the logical peer namespace or
|
||||
local database identity changes. It retains them across transport-only and
|
||||
policy-only replacement. Watch may follow a later advertised change, but does
|
||||
not immediately repeat a completed baseline transfer.
|
||||
|
||||
### Define the P2P trigger matrix
|
||||
|
||||
| Trigger | Required preconditions | Effect |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| `P2P_AutoStart` | P2P enabled, resumed lifecycle, cleared host closure, and no user disconnect veto | Open room; do not itself transfer files |
|
||||
| `P2P_AutoSyncPeers` | Open room, matching advertisement, and accepted peer policy | Run one bidirectional finite synchronisation |
|
||||
| `P2P_AutoWatchPeers` | Open room, matching accepted advertisement, and remote broadcasting | Pull later announced updates |
|
||||
| `P2P_AutoBroadcast` | Open room and local broadcasting enabled | Announce later local database changes |
|
||||
| `P2P_SyncOnReplication` | Cleared host closure, no disconnect veto, configured names, and advertisements within a bounded wait | Run target-aware unattended OneShot Sync |
|
||||
| Explicit peer command | Supplied peer target and accepted connection | Run user-owned finite synchronisation |
|
||||
| Incoming `reqSync` | Accepted peer and ordinary readiness | Pull from requesting peer |
|
||||
|
||||
`P2P_AutoStart` is a transport policy, not central Continuous replication and
|
||||
not `syncOnStart`. AutoSync, AutoWatch, and accepted incoming requests remain
|
||||
unattended when their persisted policies permit them. A configured-target
|
||||
request waits for advertisement for a bounded period; it does not inspect a
|
||||
possibly stale snapshot immediately after opening. Missing, undiscovered,
|
||||
unaccepted, or partly successful targets are explicit operation results. An
|
||||
unknown peer never opens an acceptance dialogue on an unattended path.
|
||||
An enabled P2P provider with an empty configured target set settles as
|
||||
`blocked/no-targets`; it is not reported as an unconfigured provider.
|
||||
|
||||
Delayed opens belong to the lifecycle generation which scheduled them.
|
||||
Suspension cancels them or makes them harmless, and the callback rechecks
|
||||
current settings and suspension state before opening. Ordinary automation uses
|
||||
the trigger-aware readiness policy rather than bypassing readiness, pending-file
|
||||
settlement, clean-up, or version gates. Fetch and Rebuild retain their
|
||||
separately authorised bypasses.
|
||||
|
||||
### Arbitrate Setup signalling checks through the room owner
|
||||
|
||||
The current Setup check establishes only whether its signalling transport can
|
||||
be opened. It does not validate peer discovery, room credentials against
|
||||
another device, or a TURN or WebRTC data path. The host therefore receives a
|
||||
`P2PConnectionProbeAdmission` view over the existing room-session owner instead
|
||||
of constructing an uncoordinated second transport.
|
||||
|
||||
The admission receives the requested relay settings and a continuation which
|
||||
owns one short-lived raw signalling trial. The room owner serialises the whole
|
||||
decision on its existing lifecycle queue:
|
||||
|
||||
- a serving room whose active relay set covers every requested relay returns
|
||||
`observed-active` without entering the continuation;
|
||||
- a serving room which does not cover the requested relay set returns the
|
||||
stable `active-p2p-relay-binding-conflict` decision code without entering the
|
||||
continuation; and
|
||||
- an idle owner runs the continuation and does not settle admission until the
|
||||
caller has disposed the raw Replicator and its temporary database.
|
||||
|
||||
Relay admission uses the same split-and-trim projection as transport setup,
|
||||
then compares de-duplicated sets. It does not infer URI equivalence which the
|
||||
Trystero relay key does not implement. The continuation must not await another
|
||||
lifecycle transition on the same service while it holds this serialisation
|
||||
boundary.
|
||||
|
||||
This view adds neither a second room owner nor a process-global relay lease.
|
||||
It does not change raw `TrysteroReplicator.dispose()` semantics, pause or close
|
||||
an active relay, or silently retire the active service to make an incompatible
|
||||
trial possible. A future check which genuinely needs peer-, room-, TURN-, or
|
||||
WebRTC-level evidence requires its own bounded contract rather than widening
|
||||
this signalling-only result implicitly.
|
||||
|
||||
### Keep provider composition explicit
|
||||
|
||||
The stable P2P service can be composed even when P2P is not the selected main
|
||||
provider, while the generic provider table in Part 1 can include or omit the
|
||||
P2P provider at compile time. No runtime unknown-provider registry is implied.
|
||||
When P2P is selected as main, its active adapter delegates to this same service
|
||||
and does not publish a second P2P lifecycle owner.
|
||||
|
||||
## Consequences
|
||||
|
||||
- P2P room, signalling-check admission, peer, watch, acceptance, transfer,
|
||||
configuration, and diagnostics have one explicit owner and focused
|
||||
contracts.
|
||||
- Disposing an active adapter cannot close a policy-owned or adjunct room.
|
||||
- Explicit user disconnect has a clear veto boundary and cannot be undone by
|
||||
AutoStart or a finite operation.
|
||||
- Host lifecycle closure cannot be undone by settings reconciliation or a
|
||||
finite operation before an explicit resume boundary.
|
||||
- Finite operations can request a room without changing persistent room policy.
|
||||
- Settings and local database replacement cannot publish mixed-session state.
|
||||
- Reconnects do not repeat completed baseline transfers merely because the room
|
||||
epoch changed.
|
||||
- Setup can observe a compatible active signalling binding or run an idle
|
||||
trial without mutating the active transport; an incompatible active binding
|
||||
is reported explicitly.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not make the P2P service a general service locator.
|
||||
- Do not expose room, raw host, peer connection, or concrete Replicator state
|
||||
to ordinary consumers.
|
||||
- Do not make P2P own a central remote database.
|
||||
- Do not reinterpret P2P AutoStart as `syncOnStart` or central Continuous
|
||||
replication.
|
||||
- Do not replace the accepted Trystero physical-peer and relay ownership
|
||||
decisions.
|
||||
- Do not add unbounded retry loops or pretend that a missing lower-level stop
|
||||
operation is end-to-end cancellation.
|
||||
|
||||
## References
|
||||
|
||||
- [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md)
|
||||
- [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md)
|
||||
- [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
|
||||
- [P2P Transport Compatibility Controls](2026_08_p2p_transport_compatibility.md)
|
||||
- [Bounded Remote Activity](2026_07_bounded_remote_activity.md)
|
||||
@@ -0,0 +1,720 @@
|
||||
---
|
||||
date: 2026-08-27
|
||||
commonlib-version: "0.1.20"
|
||||
self-hosted-livesync-version: "1.0.21"
|
||||
status: proposed
|
||||
series: replicator-capabilities-and-lifecycle
|
||||
part: 3 of 3
|
||||
---
|
||||
|
||||
# Architectural Decision Record: Replicator Capabilities and Lifecycle Orchestration — Part 3: Migration Plan and Verification
|
||||
|
||||
Series navigation: this is Part 3 of 3. Start with [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md),
|
||||
then [Part 2: P2P service and session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md).
|
||||
This part owns implementation sequencing and verification; it does not add
|
||||
another runtime contract.
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. The stages below are an implementation and verification order, not
|
||||
independently releasable states. Commonlib and Self-hosted LiveSync must not
|
||||
publish temporary support boundaries described by an incomplete stage. A
|
||||
release follows only after the target matrix, ownership boundaries, and the
|
||||
contracted production-consumer migrations in Parts 1 and 2 are complete.
|
||||
|
||||
## Migration rules
|
||||
|
||||
Each Commonlib change first runs its focused unit and type-contract tests,
|
||||
builds and validates the packed artefact, installs that exact artefact in
|
||||
Self-hosted LiveSync, and runs focused downstream tests before either
|
||||
repository advances. Compatibility methods remain until every production
|
||||
consumer has migrated.
|
||||
|
||||
The smallest vertical contract which fixes issue 1140 takes priority over
|
||||
unrelated probe, Fast Fetch, maintenance, integrity, and facade work. A stage
|
||||
must not claim completion when it only changes a type declaration while a
|
||||
production caller still uses the old ownership or interaction path.
|
||||
|
||||
## Stage 1: reproduce the current boundary failures
|
||||
|
||||
Add the smallest regression which configures an Object Storage active provider,
|
||||
enables `syncOnStart`, invokes the resume lifecycle after readiness, and
|
||||
expects one unattended finite synchronisation. Run it against 1.0.21 and
|
||||
confirm the expected failure: the CouchDB-owned lifecycle handler excludes
|
||||
Object Storage. Cover both an ordinary Object Storage profile and a migrated
|
||||
profile which retains `liveSync: true`; unsupported Continuous must not suppress
|
||||
the supported `syncOnStart` OneShot policy.
|
||||
|
||||
Add passing characterisation tests which inventory automatic P2P periodic,
|
||||
database-save, editor-save, file-open, and merge triggers, together with P2P
|
||||
AutoSync, AutoWatch, incoming-request, and nested finite-activity paths. These
|
||||
tests record current ownership and dialogue behaviour so that later stages do
|
||||
not accidentally remove a valid automatic path.
|
||||
|
||||
## Stage 2: fix issue 1140 through the minimum vertical contract
|
||||
|
||||
Add the fixed provider definitions and the minimum user-initiated, unattended,
|
||||
and Continuous roles to Commonlib. Give `ReplicationService` typed entry points
|
||||
which carry trigger and interaction policy. Immediately before changing
|
||||
Object Storage handling, add a failing test in which a stopped or failed
|
||||
journal transfer must not produce a completed outcome; then propagate the
|
||||
actual journal result.
|
||||
|
||||
Add the LiveSync-owned replication scheduling serviceFeature, remove the resume
|
||||
handler from `ModuleReplicatorCouchDB`, and route CouchDB Continuous and OneShot
|
||||
Sync plus Object Storage `syncOnStart` through `ReplicationService`. Its private
|
||||
context owns scheduling state, module-level functions implement transitions,
|
||||
and the serviceFeature owns lifecycle and settings-handler registration.
|
||||
Migrate every automatic caller to
|
||||
the unattended entry point in this stage, so periodic and event calls cannot
|
||||
fall back to an interactive P2P role. Migrate manual commands to the
|
||||
user-initiated entry point.
|
||||
|
||||
Replace the factory-registration-only responsibilities of
|
||||
`ModuleReplicatorCouchDB` and `ModuleReplicatorMinIO` with composed provider
|
||||
definitions. Retain a module only for separately identified stateful
|
||||
behaviour; do not retain an instance merely to add a construction handler.
|
||||
|
||||
Serialise active initialisation, replacement, and disposal. Publish the active
|
||||
provider and Replicator as one context after initialisation, clear that context
|
||||
before retiring the old adapter, and keep each typed dispatch on one context
|
||||
snapshot. This is the minimum publication fence for this stage. Waiting for
|
||||
in-flight adapter work and making acquisitions wait for replacement settlement
|
||||
remain part of the later active-construction migration.
|
||||
|
||||
The scheduling context coalesces its network work internally, but an
|
||||
`onResumed` handler settles once that work has been scheduled. It does not hold
|
||||
later resume consumers until a OneShot transfer or Continuous start has
|
||||
settled. Pass one private context with narrow replication, settings, lifecycle,
|
||||
timer, and logging collaborators to module-level functions. Return only the
|
||||
daemon-facing control view, pass it to host composition, and inject it into the
|
||||
CLI command context. Do not retain the view as a public `LiveSyncBaseCore`
|
||||
property or retain scheduling state in a core-keyed `WeakMap`.
|
||||
|
||||
At this boundary, existing P2P AutoSync, AutoWatch, and incoming-request
|
||||
entry points receive the same non-interactive readiness and accepted-peer gate.
|
||||
The no-interaction authority reaches counterpart RPC authorisation and
|
||||
broadcast progress notifications. An unknown peer is blocked rather than
|
||||
prompting.
|
||||
|
||||
Add focused Commonlib owner tests which start an unattended finite room demand
|
||||
before AutoStart demand and after AutoStart demand. Both orders retain one room
|
||||
until every remaining demand has settled. The LiveSync feature-binding test
|
||||
must not rely on the current registration order of equal-priority resume
|
||||
handlers.
|
||||
|
||||
Until Stage 4 supplies target-aware unattended P2P, each host composition
|
||||
declares generic `P2P_SyncOnReplication` as `not-implemented`. Its automatic
|
||||
request settles without UI with an explicit blocked result. Existing AutoSync,
|
||||
AutoWatch, and accepted incoming-request paths continue with the Stage 2 gate.
|
||||
This is a temporary migration state, not the target matrix in Part 1.
|
||||
|
||||
Apply and test the CLI scheduling precedence defined in Part 1, so the daemon
|
||||
and scheduling context cannot schedule duplicate initial or recurring work.
|
||||
Replace `ModuleReplicationLifecycle` and the replication-specific
|
||||
`ModulePeriodicProcess` wiring only after equivalent context and feature-
|
||||
binding tests pass. Reuse the existing timer implementation behind a narrow
|
||||
timer port; changing other periodic feature owners is outside this stage.
|
||||
|
||||
## Stage 3: make P2P transport ownership truthful
|
||||
|
||||
Introduce the stable P2P service and its narrow contract views around the
|
||||
existing implementation while preserving transfer semantics and changing the
|
||||
necessary ownership and lifecycle behaviour. Make the active P2P Replicator a
|
||||
non-owning adapter. Give the service exclusive ownership of room sessions,
|
||||
session-epoch fencing, effective session binding, room open/close/replacement,
|
||||
and settings reconciliation. Consolidate overlapping resume and
|
||||
settings-event handlers.
|
||||
|
||||
Migrate Obsidian panes and commands to lifecycle, peer, admission, transfer,
|
||||
change-relay, configuration-exchange, and diagnostic views as required. Migrate
|
||||
CLI and WebPeer away from concrete-class checks and raw host or room access.
|
||||
Preserve RTC diagnostics through `P2PDiagnostics`, rather than retaining
|
||||
`rawHost`. The compatibility facade may delegate during this stage, but new
|
||||
consumers cannot receive it.
|
||||
|
||||
Separate active-adapter release, room-session leave, and the stop request.
|
||||
Implement the lower-level cooperative cancellation path before declaring the
|
||||
P2P stop role supported: caller abort through RPC request cancellation,
|
||||
incoming-handler signal propagation, signal-bound reverse database RPC calls,
|
||||
and safe batch-boundary termination in `replicateShim`. Add room-session and
|
||||
operation controllers beside internal session-demand ownership for finite
|
||||
operations and policy-held AutoStart, without exposing that bookkeeping as a
|
||||
general consumer API.
|
||||
|
||||
Add ownership regressions immediately before implementation:
|
||||
|
||||
- replacing or disposing the active main adapter does not close a policy-owned
|
||||
or adjunct room;
|
||||
- a disposed session fences late callbacks and clients;
|
||||
- a P2P setting change replaces an adjunct room while another provider remains
|
||||
the active main remote;
|
||||
- persisted peer decisions survive replacement, while temporary decisions and
|
||||
advertisements do not;
|
||||
- local database replacement retires database-bound feeds and publication
|
||||
before manager teardown;
|
||||
- explicit database close settles every dependent cleanup owner before closing
|
||||
the physical handle, even when an earlier cleanup reports failure;
|
||||
- repeated replacement does not retain platform-event subscriptions;
|
||||
- an active-transfer stop aborts finite operations without closing the room,
|
||||
while a later operation can use the same room;
|
||||
- room retirement aborts both locally initiated and incoming `reqSync` work,
|
||||
waits for an already-started atomic database operation to settle, and starts
|
||||
no later batch;
|
||||
- RPC cancellation, timeout, peer departure, and room close abort a
|
||||
cancellation-aware handler rather than only discarding its eventual result;
|
||||
- the inbound request context exists before request admission begins, so a
|
||||
cancellation received while admission waits cannot be lost;
|
||||
- cancellation retains already-settled documents and checkpoints and reports
|
||||
`cancelled`, rather than claiming rollback or completion;
|
||||
- a per-document batch-write failure does not advance the replication
|
||||
checkpoint past the failed revision;
|
||||
- explicit disconnect suppresses AutoStart and relay reconnection until
|
||||
explicit connect, while a separately authorised rebuild continuation can
|
||||
reopen the room without clearing that automatic-start veto;
|
||||
- a candidate whose settings, device identity, or database binding changes
|
||||
while it opens is retired instead of published; and
|
||||
- database replacement fences both active-provider and P2P work before
|
||||
publishing the new database identity, while a failed candidate leaves one
|
||||
observable disconnected state without reviving the fenced session.
|
||||
|
||||
When this stage lands, add a supersession note to the accepted P2P lifecycle
|
||||
record and update `devs.md` from the replaceable concrete Replicator getter to
|
||||
the stable contract views. Preserve the accepted Trystero peer and relay
|
||||
ownership rules rather than rewriting their historical verification.
|
||||
|
||||
## Stage 4: add target-aware unattended P2P orchestration
|
||||
|
||||
Before implementation, add failing regressions for the headless result-loss
|
||||
path, delayed advertisement, an unaccepted peer, overlapping configured-target
|
||||
and AutoSync requests, suspension before delayed open, and a finite-operation
|
||||
demand beside policy-owned AutoStart demand.
|
||||
|
||||
Implement target-aware unattended P2P work without UI. Add bounded
|
||||
advertisement waiting, peer-acceptance outcomes, finite-operation demands beside
|
||||
policy demands, lifecycle-generation cancellation for delayed opens, and
|
||||
de-duplication across AutoSync and configured-target baseline requests. Keep
|
||||
AutoWatch as the relay for later changes rather than treating it as another
|
||||
baseline transfer.
|
||||
Route P2P automation through trigger-aware readiness without central-remote
|
||||
Security Seed preflight. Add the missing finite-activity boundary to direct
|
||||
shared-pane synchronisation.
|
||||
|
||||
Keep the detailed wait, session-demand, de-duplication, and session-epoch state
|
||||
machine in Part 2 rather than expanding the generic provider contract. If
|
||||
implementation evidence requires a refinement, amend Part 2 before completing
|
||||
this stage. After Stage 3 is complete, Part 2 supersedes the
|
||||
replaceable-Replicator and current-result ownership portions of the accepted
|
||||
July 2026 record; its Trystero peer and relay decisions remain unchanged.
|
||||
|
||||
Commonlib's `docs/p2p-transport-lifecycle.md` design document records the
|
||||
implemented Stage 3 and Stage 4 ownership, demand, automation, replacement,
|
||||
and shutdown behaviour. This document remains the migration and verification
|
||||
sequence rather than a second description of the implemented state.
|
||||
|
||||
## Stage 5: separate active construction and flow-specific probes
|
||||
|
||||
Make active creation a private, exhaustive `ReplicatorService` operation with
|
||||
one configuration identity and replacement policy. Migrate every non-active caller
|
||||
before restricting `getNewReplicator()`: CouchDB connection and passphrase
|
||||
checks, Object Storage connection and preferred-tweak trials, P2P Setup
|
||||
signalling trials, CLI commands, and other host compositions. Prove that every probe leaves
|
||||
the active main Replicator and adjunct P2P transport unchanged.
|
||||
|
||||
Migrate Streaming Fetch to the owned CouchDB initial-transfer dependencies
|
||||
defined in Part 1. Add replacement-fence, late-settlement,
|
||||
configuration-identity, cache-invalidation, and probe-disposal tests before
|
||||
making active construction private.
|
||||
|
||||
### Implementation position after Stage 5
|
||||
|
||||
The provider-defined active-construction path is now private to
|
||||
`ReplicatorService`. The public `getNewReplicator` handler remains as a
|
||||
compatibility surface, but current Self-hosted LiveSync production code no
|
||||
longer calls it. Its removal belongs to Stage 7 after any external compatibility
|
||||
decision has been made.
|
||||
|
||||
The current host composition has migrated CouchDB and Object Storage connection
|
||||
checks, passphrase inspection, preferred-tweak reads, CLI remote status and
|
||||
administration, P2P Setup, and Streaming Fetch Security Seed access away from
|
||||
active construction and towards owned resources or focused services. Active
|
||||
replacement is serialised, context acquisition waits for a queued replacement,
|
||||
late candidates are fenced, and short-lived resources are disposed.
|
||||
|
||||
Stage 5 made the P2P Setup trial separately owned, but did not yet arbitrate it
|
||||
against the active relay binding held by the stable P2P service. The Stage 7
|
||||
review identified and completed that remaining owner boundary; it did not
|
||||
reopen the active-construction contract.
|
||||
|
||||
This position completes the Stage 5 construction and probe boundary. It is not
|
||||
itself a release decision: the active-publication and truthful-attempt work in
|
||||
Stage 6 remains required. Complete retirement of the compatibility facade is
|
||||
not a prerequisite for issue 1140.
|
||||
|
||||
## Stage 6: harden the active lifecycle and exact attempt outcome
|
||||
|
||||
Replace the active dependency on `LiveSyncAbstractReplicator` with the small
|
||||
`ReplicatorInstance` contract. Retain a publication only while its provider and
|
||||
private configuration identity are unchanged. Every changed identity fences
|
||||
new admission, requests supported transfer cancellation, drains admitted work,
|
||||
closes the old instance, and only then constructs and publishes a replacement.
|
||||
|
||||
Reserve the exact publication around typed finite dispatch and the currently
|
||||
required workflow-local directional attempts. Release before recovery or a
|
||||
dialogue. CouchDB and Journal record an immutable compatibility decision inside
|
||||
the attempt which owns the connection or borrowed client, and a later mutation
|
||||
must re-admit that failed context. Retry and Continuous paths reassess on each
|
||||
new CouchDB connection without adding another logical connection.
|
||||
|
||||
Preserve Journal storage read states through `getSyncParameters()`. Only an
|
||||
explicit `not-found` result permits `SyncParamsHandler` to create and upload new
|
||||
synchronisation parameters. An unavailable read becomes a fetch failure and
|
||||
must not regenerate the shared Security Seed. This fixes issue 1147 within the
|
||||
owned-observation boundary rather than adding another Replicator capability.
|
||||
|
||||
### Contracted Stage 6 implementation position
|
||||
|
||||
The retained implementation is deliberately smaller than the earlier complete
|
||||
consumer-migration proposal:
|
||||
|
||||
- `ReplicatorInstance` contains only initialisation, `openReplication`,
|
||||
transfer termination, and close;
|
||||
- provider definitions retain user-initiated and unattended OneShot runners,
|
||||
an explicit Continuous support decision, readiness, transfer stop, the four
|
||||
owned remote resources, and one optional cohesive central-remote administration
|
||||
runner;
|
||||
- every changed configuration identity replaces the active instance; there is
|
||||
no same-instance rebind policy;
|
||||
- the atomic active context retains that private identity, and every
|
||||
settings-bearing dispatch checks its captured settings against the admitted
|
||||
publication immediately before calling provider code;
|
||||
- typed finite dispatch reserves the exact readiness-tested publication and
|
||||
releases it before failure recovery;
|
||||
- the bounded Continuous startup call and explicit stop request re-admit their
|
||||
exact publication, while a registered long-lived task remains outside a
|
||||
lifetime reservation;
|
||||
- directional transfer and central-remote administration stop active transfer
|
||||
work inside the same admission before beginning their exclusive operation;
|
||||
- CouchDB and Journal carry only a rejected attempt-local compatibility
|
||||
decision into the failure outcome, so a transport failure cannot reuse old
|
||||
mutable fields;
|
||||
- mismatch updates, unlock, and cleaned-remote reconciliation re-admit the
|
||||
failed publication before remote mutation;
|
||||
- P2P uses a narrow non-owning active adapter, takes the same typed finite path,
|
||||
supports its real download workflow, and exposes no central facility;
|
||||
- ordinary central preparation and Streaming Fetch use owned Security Seed
|
||||
resources which dispose their unpublished compatibility instances;
|
||||
- CLI synchronisation diagnoses lock and clean rejection from the exact outcome.
|
||||
Established successful output and exit behaviour are preserved. Typed
|
||||
remote-administration verification failures return non-zero by default;
|
||||
`--compat-remote-admin-exit-zero` restores the former zero result only for
|
||||
returned verification failures, while thrown mutation failures remain
|
||||
non-zero; and
|
||||
- Journal unavailable sync-parameter reads cannot enter the create-and-upload
|
||||
branch required only by explicit absence.
|
||||
|
||||
The active path no longer depends on the giant facade. Existing maintenance,
|
||||
remote-size, on-demand Chunk, migration-inspection, and compatibility consumers
|
||||
may still use focused structural checks or the legacy facade. Their complete
|
||||
migration, local-node-identity redesign, generic milestone extraction, and
|
||||
in-process local-database reset redesign are deferred unless a separate bounded
|
||||
change proves that they are required.
|
||||
|
||||
Each production correction has a focused regression. The verification section
|
||||
distinguishes local source and packed-consumer evidence from registry,
|
||||
real-runtime, and release validation.
|
||||
|
||||
### Active-publication quiescing boundary
|
||||
|
||||
Commonlib implements the publication-scoped callback reservation defined in
|
||||
Part 1 for finite provider dispatch, central-remote administration, exact
|
||||
recovery mutation, and the workflow-local directional attempts which use the
|
||||
active instance. Invocation queues its admission decision in the same order as
|
||||
replacement and disposal. The publication object is its private generation
|
||||
identity. Once admitted, release is idempotent, private, and independent of
|
||||
that queue so quiescing cannot prevent the operation which allows its own drain
|
||||
to settle.
|
||||
|
||||
Every switch follows the same order: fence admission, request supported
|
||||
transfer cancellation, drain admitted callbacks, close, then publish another
|
||||
context. An unchanged provider and configuration identity keeps the existing
|
||||
publication.
|
||||
|
||||
The first implementation regressions cover:
|
||||
|
||||
- replacement waiting for an admitted exact-context task while ignoring an
|
||||
unrelated bounded activity;
|
||||
- context acquisition waiting for a queued replacement rather than returning a
|
||||
stale or intermediate publication;
|
||||
- rejecting central-remote administration releasing its reservation before
|
||||
replacement continues;
|
||||
- finite failure recovery starting only after publication release;
|
||||
- directional workflow retry releasing and then re-admitting the same context;
|
||||
and
|
||||
- terminal unload draining admitted work before Replicator and local-database
|
||||
close, while reversible suspension retains the publication.
|
||||
|
||||
Keep readiness, failure presentation, dialogue, independently owned trial
|
||||
resources, P2P room-session demand, and the lifetime of Continuous replication
|
||||
outside this reservation. Re-admit only its bounded startup call and the
|
||||
bounded explicit stop request. The callback TSDoc forbids awaiting a lifecycle
|
||||
transition which would wait for the same admission to settle.
|
||||
|
||||
## Stage 7: perform a bounded structural review
|
||||
|
||||
Confirm that the contracted core is minimum and robust before a commit or
|
||||
release decision. The active path must stay independent of
|
||||
`LiveSyncAbstractReplicator`, but complete migration of every compatibility
|
||||
consumer is separate work. Remove a dummy or abstract requirement only when
|
||||
its current callers have a truthful alternative; do not turn facade retirement
|
||||
into a condition for issue 1140.
|
||||
|
||||
The retained compatibility surface includes the broad
|
||||
`LiveSyncBaseCore.replicator` view and concrete central adapters required by
|
||||
maintenance and migration code. It is explicitly a partial compatibility view,
|
||||
not the active provider contract. P2P's active adapter does not inherit it.
|
||||
|
||||
CouchDB and Object Storage Journal both have a real central milestone document,
|
||||
but that fact alone does not justify a generic store. Extract a focused contract
|
||||
only when a second current caller demonstrates the same ownership and mutation
|
||||
semantics. P2P has no central milestone document and must not receive a stub or
|
||||
synthetic implementation.
|
||||
|
||||
The final structural review retains the following minimum boundaries:
|
||||
|
||||
- central provider definitions remain outside `LiveSyncBaseCore`; its thin
|
||||
registration method remains as the construction-order composition boundary;
|
||||
- P2P registration remains with the feature which owns the stable P2P service;
|
||||
- `ReplicatorService` and `ReplicationService` remain cohesive services. Their
|
||||
complex state and policy already reside in the focused
|
||||
`ReplicatorService.activeReplicatorState`,
|
||||
`ReplicationService.typedReplication`, `ReplicationService.readiness`,
|
||||
`ReplicatorService.remoteResourceResolver`, and
|
||||
`ReplicatorService.centralRemoteAdministration` collaborators, so another
|
||||
split would not improve the current test seams;
|
||||
- every provider explicitly declares Continuous support or inapplicability;
|
||||
- new typed work uses only acquired or admitted active-context access. The
|
||||
public legacy Replicator getter remains temporarily for named compatibility
|
||||
consumers, while one side-effect-free presence predicate classifies a legacy
|
||||
active instance without returning it or emitting its missing-active Notice.
|
||||
A protected synchronous context inspector remains for focused lifecycle
|
||||
tests;
|
||||
- central-compatibility statuses, recorders, and projection helpers remain
|
||||
package-internal. Only stable rejection reason codes and public recovery
|
||||
value types remain package-index exports; and
|
||||
- redundant recovery-kind data, unused active-state identity matching, and the
|
||||
externally visible legacy administration lookup helper are removed.
|
||||
|
||||
LiveSync implements its optional central-remote administration facility through one
|
||||
shared protocol executor and provider-specific milestone readers. Provider
|
||||
composition selects the reader; a reader validates only the structural
|
||||
operations required for its CouchDB connection or Journal client before any
|
||||
remote mutation. It does not impose concrete-class identity on the generic
|
||||
runner contract.
|
||||
|
||||
The unpublished public contract, provider field, coordinator, and service
|
||||
operation use the `CentralRemoteAdministration*` name because their complete
|
||||
protocol is central-milestone-specific. The central OneShot adapters use a
|
||||
separate local structural operation instead of concrete-class identity. Stable
|
||||
capability-kind comparisons use `CAPABILITY_SUPPORT_KINDS`, and unavailable
|
||||
capabilities settle once without a redundant post-narrowing check.
|
||||
|
||||
Provider-specific configuration identities remain explicit projections of the
|
||||
settings which bind each adapter. They are not replaced with a generic identity
|
||||
builder: the current URL normalisation and setting lists are clearer at the
|
||||
provider boundary, and changing the shared header parser is separate work.
|
||||
|
||||
The review records, but does not prejudge, whether maintained Rebuild and Fetch
|
||||
workflows still require an in-process local-database reset. Prefer a Flag File and restart
|
||||
boundary if it can preserve user intent, CLI behaviour, failure recovery, and
|
||||
the maintained test workflows without losing a supported continuation path.
|
||||
Until that evidence exists, retain the current reset contract and its explicit
|
||||
Replicator-retirement ordering rather than assuming that every workflow has
|
||||
already moved to a restart.
|
||||
|
||||
It also records consumers which retain `LiveSyncLocalDB`, its physical PouchDB
|
||||
handle, or its managers. If broad retention still leaks ownership after the
|
||||
consumer migrations, keep the physical handle and teardown authority in
|
||||
`LiveSyncLocalDB`, and expose only the smallest read-only view or
|
||||
generation-bound operation contract required by each consumer. Do not cache a
|
||||
detached handle snapshot across reset. Preserve the current single active
|
||||
database contract, and add another abstraction only where the inventory shows
|
||||
a concrete lifetime or testability benefit. These recorded questions do not
|
||||
widen Stage 6 or make an anticipatory database abstraction part of this change.
|
||||
|
||||
### Contracted Stage 7 review position
|
||||
|
||||
The structural review retains the contracted service boundaries. The active
|
||||
path remains independent of `LiveSyncAbstractReplicator`; the partial
|
||||
compatibility facade, focused maintenance consumers, and in-process database
|
||||
reset remain bounded deferred work. `ReplicatorService` and
|
||||
`ReplicationService` continue to delegate their complex state and sequencing
|
||||
to the focused collaborators listed above, so another physical service split
|
||||
would add indirection without removing a current responsibility or improving a
|
||||
current test seam.
|
||||
|
||||
One concrete compatibility risk remains deferred with that maintenance work.
|
||||
Journal `tryResetRemoteDatabase()` and `tryCreateRemoteDatabase()` synchronously
|
||||
close and replace their lazy client without first awaiting the transfer
|
||||
settlement owned by `terminateSync()`. CouchDB awaits its corresponding close.
|
||||
Maintained reset and rebuild workflows normally stop ordinary synchronisation
|
||||
before destructive remote work, but the Journal compatibility methods neither
|
||||
encode nor independently test that precondition. A later bounded change must
|
||||
first reproduce the race, then choose workflow-owned suspension, admitted
|
||||
maintenance, or same-instance transfer settlement. It must not add a generic
|
||||
provider capability merely to retire the compatibility facade.
|
||||
|
||||
The review did identify five bounded behavioural corrections inside existing
|
||||
owners:
|
||||
|
||||
- Security Seed resources force a fresh provider read for their settings
|
||||
snapshot instead of accepting process-cached synchronisation parameters as
|
||||
current evidence;
|
||||
- local-database close, reset, failed-initialisation rollback, and
|
||||
physical-database close clean-up share and await the existing
|
||||
active-Replicator retirement for one physical database lifetime;
|
||||
- disposing an unused Journal resource does not report that replication
|
||||
closed, while a Replicator lifecycle message is emitted only when a real
|
||||
active publication is retired and no longer mislabels unload as database
|
||||
reset;
|
||||
- unattended P2P no-target, authentication, tweak-mismatch, and
|
||||
overlapping-transfer settlements retain informational diagnostics without
|
||||
creating Notice-level presentation; and
|
||||
- P2P Setup receives the stable service's `P2PConnectionProbeAdmission` view.
|
||||
Compatible active relay bindings are observed, an active binding which does
|
||||
not cover the requested relay set is blocked with a stable decision code,
|
||||
and only an idle owner runs and awaits the caller-owned raw trial.
|
||||
|
||||
The last correction uses `P2PRoomSessionOwner`'s existing lifecycle queue and
|
||||
adds no room owner, global relay lease, reference count, or raw transport
|
||||
disposal policy. Every maintained production opening of the P2P Setup dialogue
|
||||
passes through one host-owned `SetupManager` seam, which injects the admission
|
||||
view explicitly. The decision code remains separate from the LiveSync-owned
|
||||
English presentation message.
|
||||
|
||||
These corrections close ownership and presentation gaps found by the review;
|
||||
they do not add another generic capability, remote resource, probe framework,
|
||||
or provider role. The target matrices in Part 1 therefore remain unchanged.
|
||||
|
||||
A subsequent falsification and quality pass found further bounded corrections
|
||||
inside the same owners:
|
||||
|
||||
- directional failures retain the immutable compatibility hint from their
|
||||
exact admitted attempt, and recovery re-admits that failed context;
|
||||
- settings-bearing dispatches correlate their snapshot with the active
|
||||
configuration identity, while bounded Continuous startup and explicit stop
|
||||
re-admit the exact publication;
|
||||
- directional transfer and central-remote administration stop active transfer
|
||||
work before their exclusive operation;
|
||||
- readiness calls the application lifecycle method, rather than testing the
|
||||
method object;
|
||||
- cancelled or incomplete P2P pull and push outcomes are not reported as
|
||||
success by the CLI or Obsidian UI, and ordinary UI transfer uses the stable
|
||||
targeted-transfer view rather than the compatibility Replicator. The
|
||||
retained compatibility entry opens that ordinary UI only; it does not
|
||||
perform the transfer;
|
||||
- Journal stop does not construct an unused client, and waits for the transfer
|
||||
promises admitted at its stop boundary, including synchronous setup re-entry;
|
||||
and
|
||||
- remote-size inspection uses one settings snapshot and preserves an observed
|
||||
zero rather than treating it as absence.
|
||||
|
||||
These are lifecycle, settlement, and compatibility-consumer corrections. They
|
||||
do not expand the capability or facility tables.
|
||||
|
||||
A later contract reconciliation found four more bounded truthfulness gaps
|
||||
inside the same existing owners:
|
||||
|
||||
- Object Storage central milestone mutation and postcondition verification
|
||||
preserve `available`, `not-found`, and `unavailable`; only explicit absence
|
||||
can initialise a document, and unavailability cannot upload or become a
|
||||
missing-milestone result;
|
||||
- an enabled P2P provider with no configured targets preserves the Replicator's
|
||||
`blocked/no-targets` result instead of reporting provider absence;
|
||||
- typed active acquisition classifies expected absence through a non-owning,
|
||||
side-effect-free presence predicate rather than the Notice-producing legacy
|
||||
getter; and
|
||||
- the CLI prints a stable explanation when central administration is rejected
|
||||
because the active configuration changed before admission.
|
||||
|
||||
These corrections require no new capability, resource family, state machine,
|
||||
or presentation framework.
|
||||
|
||||
A subsequent lifecycle and diagnostic falsification found four further regressions
|
||||
at those established boundaries:
|
||||
|
||||
- CouchDB synchronisation-information inspection now distinguishes an observed
|
||||
incompatibility from connection, setup, or verification failure, so the
|
||||
settings flow retains its separate existing messages;
|
||||
- an explicitly visible CouchDB connection probe retains its established
|
||||
success or failure Notice, while silent probe consumers remain silent;
|
||||
- a private Journal Stop generation prevents a connectivity preflight which
|
||||
crossed a later Stop boundary from starting a client transfer; and
|
||||
- host P2P lifecycle closure establishes a private reversible gate which
|
||||
settings reconciliation and finite views cannot clear. Explicit connect,
|
||||
database-rebuild continuation, and resumed AutoStart scheduling remain the
|
||||
declared reopen boundaries.
|
||||
|
||||
These corrections refine existing resource, transfer-stop, and P2P lifecycle
|
||||
semantics. They add no provider capability, resource kind, public state, or
|
||||
presentation framework, so the Part 1 matrices remain unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
### Commonlib unit and type-contract tests
|
||||
|
||||
Cover:
|
||||
|
||||
- exhaustive host-composed definitions for CouchDB, Object Storage, and P2P;
|
||||
- the four-method active `ReplicatorInstance` contract and provider-owned
|
||||
runtime roles;
|
||||
- user-initiated, unattended, blocked, partial, cancelled, and failed OneShot
|
||||
outcomes;
|
||||
- truthful Object Storage stop or transfer failure and headless P2P outcomes;
|
||||
- active, quiescing, disposed, and replacement-published states, including
|
||||
rejection of new work during retirement, acquisition waiting, and late
|
||||
candidate settlement;
|
||||
- unchanged-identity retention, changed-identity replacement, idempotent
|
||||
reservation release, settings-bearing dispatch correlation, and close
|
||||
ordering;
|
||||
- probes which cannot replace the active Replicator or P2P service, including
|
||||
P2P Setup observation and blocking without trial construction, and idle
|
||||
admission which awaits complete disposal of the caller-owned trial;
|
||||
- the deliberately narrow active-transfer stop request, including exact
|
||||
admission, bounded Continuous startup, exclusive-operation ordering, and work
|
||||
it does not claim to cancel;
|
||||
- CouchDB compatibility and transfer using the same owned OneShot connection;
|
||||
- Journal compatibility and transfer using one settings-bound borrowed client;
|
||||
- attempt-local accepted, rejected, and not-assessed decisions, including retry
|
||||
and Continuous reassessment on newly opened CouchDB connections;
|
||||
- Journal synchronisation-parameter reads distinguishing explicit absence from
|
||||
unavailability and never writing after the latter;
|
||||
- Journal central milestone mutation rejecting unavailable reads without an
|
||||
upload, and Object Storage postcondition verification preserving the same
|
||||
read failure and diagnostic detail;
|
||||
- Journal stop avoiding lazy resource construction, settling the transfer
|
||||
promises admitted at its stop boundary, and preventing a deferred
|
||||
connectivity preflight from entering a client transfer after Stop;
|
||||
- cohesive central administration and its truthful mutation settlement;
|
||||
- the narrow non-owning P2P active adapter, including download without a
|
||||
synthetic upload or central facility;
|
||||
- local-database retirement sharing the active owner boundary across reset and
|
||||
close paths;
|
||||
- caller-authority preservation for unattended P2P presentation and truthful
|
||||
Journal and active-lifecycle closure diagnostics;
|
||||
- remote-size inspection retaining one settings snapshot and reporting a zero
|
||||
estimate as an observation;
|
||||
- P2P configured-target execution preserving `blocked/no-targets`; and
|
||||
- P2P host lifecycle closure blocking settings reconciliation and finite room
|
||||
demand until explicit connect, rebuild continuation, or resumed AutoStart;
|
||||
- expected typed absence producing no legacy missing-active Notice.
|
||||
|
||||
### Self-hosted LiveSync unit tests
|
||||
|
||||
Cover:
|
||||
|
||||
- Object Storage `syncOnStart` through resume, including a migrated profile
|
||||
which retains `liveSync: true`;
|
||||
- existing CouchDB Continuous and OneShot paths;
|
||||
- same-generation resume coalescing, a fresh attempt after a later lifecycle
|
||||
generation, and rejection of an obsolete generation's OneShot fallback;
|
||||
- a queued Periodic callback rechecking lifecycle and recurring-work ownership
|
||||
after its interval has been disabled;
|
||||
- the daemon's satisfied initial OneShot marker being consumed even when a
|
||||
Continuous start throws, so a later resume may retry normally;
|
||||
- periodic, database-save, editor-save, file-open, merge, and daemon triggers
|
||||
remaining free of dialogues;
|
||||
- manual P2P and configured peer-targeted flows remaining available;
|
||||
- ordinary Obsidian P2P transfer using the stable targeted-transfer view, with
|
||||
cancelled or incomplete pull and push outcomes remaining non-successful;
|
||||
- P2P AutoStart cancellation across suspension, bounded advertisement waiting,
|
||||
accepted peers without unattended dialogues, remote-broadcast prerequisites,
|
||||
session-demand reference counts, and overlapping peer policies;
|
||||
- the focused P2P service views sharing one room owner without exposing a raw
|
||||
host, room, or concrete Replicator, including connection-probe admission;
|
||||
- session replacement fencing callbacks, clients, temporary decisions,
|
||||
advertisements, and database-bound feeds while retaining persisted decisions;
|
||||
- counterpart RPC authorisation and broadcast progress preserving no-dialogue
|
||||
authority;
|
||||
- Setup and settings validation through owned resources or owner-arbitrated
|
||||
P2P admission, including distinct CouchDB incompatibility and operational
|
||||
failure messages, explicit visible probe Notices, and silent ordinary
|
||||
probes;
|
||||
- readiness invoking the application lifecycle predicate;
|
||||
- exact failed-context mismatch, unlock, and cleaned-remote recovery, including
|
||||
rejection after active replacement;
|
||||
- CLI lock diagnostics from the exact finite outcome rather than mutable fields
|
||||
on a later active Replicator;
|
||||
- CLI process exit codes for successful administration, returned verification
|
||||
failure with and without `--compat-remote-admin-exit-zero`, and thrown
|
||||
mutation failure, including a diagnostic for active-configuration mismatch;
|
||||
- first-device and additional-device initialisation for each current provider;
|
||||
- P2P as the main remote and as an adjunct transport; and
|
||||
- CLI, WebApp, and WebPeer composition against the same contracts, including
|
||||
daemon scheduling after settings restoration and P2P AutoStart reconciliation
|
||||
after a settings change.
|
||||
|
||||
### Focused integration and real-Obsidian verification
|
||||
|
||||
Cover an Object Storage change arriving immediately after start-up without
|
||||
waiting for the periodic interval, ordinary CouchDB start-up, and P2P start-up
|
||||
without an unexpected selection dialogue or transport replacement. P2P
|
||||
validation also covers an accepted configured peer advertising after room open,
|
||||
a watched peer whose remote side broadcasts, and suspension before a delayed
|
||||
AutoStart callback. A focused P2P Setup check must also cover an active room
|
||||
with the same relay set, an attempted additional relay which is blocked without
|
||||
closing that room, and an idle trial which releases its short-lived resources.
|
||||
Deterministic verification must also confirm that a temporarily unavailable
|
||||
synchronisation-parameter read does not upload a new Security Seed.
|
||||
|
||||
The verification report must identify which boundary each real-runtime
|
||||
scenario deliberately exercised. A broad passing suite is not direct evidence
|
||||
for Object Storage `syncOnStart`, an injected unavailable read, P2P automatic
|
||||
scheduling, or active-relay Setup arbitration unless that scenario caused the
|
||||
boundary. Deterministic unavailable-read fault injection may remain at the
|
||||
owned contract-test seam when no reliable real-runtime injection exists; the
|
||||
remaining runtime limitation must then be stated rather than represented as a
|
||||
passing end-to-end scenario.
|
||||
|
||||
The maintained Object Storage Setup URI workflow now exercises the migrated
|
||||
start-up combination deliberately. It persists `liveSync: true` and
|
||||
`syncOnStart: true` on the first device, keeps Periodic replication disabled,
|
||||
stops that device before the second device writes the return note, and then
|
||||
restarts it. The workflow waits for that note without requesting manual
|
||||
replication. Object Storage declares Continuous not applicable, so a successful
|
||||
return journey directly exercises the unattended OneShot fallback owned by
|
||||
start-up scheduling. A settings-save reconciliation before the first device
|
||||
stops cannot satisfy the assertion because the return note does not yet exist.
|
||||
|
||||
The maintained real-Obsidian P2P Setup URI workflow directly exercises Setup
|
||||
URI application, initial Fetch, peer approval and actions, explicit disconnect
|
||||
and reconnect, and bidirectional note transfer. It does not deliberately
|
||||
exercise accepted configured-peer advertising, watched-peer broadcast,
|
||||
suspension before a delayed AutoStart callback, or active-room relay
|
||||
arbitration during P2P Setup. Those exact boundaries retain deterministic unit
|
||||
or contract evidence and are not represented as direct real-runtime proof.
|
||||
|
||||
The maintained MinIO harness has no existing seam which can make exactly one
|
||||
synchronisation-parameter or milestone read unavailable. Stopping MinIO or
|
||||
using invalid credentials fails the preceding bucket-availability check,
|
||||
rather than the owned control-document read. The issue 1147 unavailable-read
|
||||
case therefore remains deterministic at the storage adapter, Journal core,
|
||||
and Replicator contract-test seams, where the assertions preserve
|
||||
`unavailable` and forbid creation or upload. It is not represented as a
|
||||
successful real-Obsidian fault-injection scenario.
|
||||
|
||||
No temporary stage is a release candidate. Release readiness requires the
|
||||
target capability matrix, the contracted core and in-scope consumer migration,
|
||||
focused downstream checks with the exact packed Commonlib artefact, and the
|
||||
real-runtime checks appropriate to the changed boundary. Complete legacy-facade
|
||||
retirement remains a separately reviewed compatibility change.
|
||||
|
||||
## References
|
||||
|
||||
- [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md)
|
||||
- [Part 2: P2P service and session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
|
||||
- [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
|
||||
- [P2P Transport Compatibility Controls](2026_08_p2p_transport_compatibility.md)
|
||||
- [Bounded Remote Activity](2026_07_bounded_remote_activity.md)
|
||||
- [Self-hosted LiveSync issue 1140](https://github.com/vrtmrz/obsidian-livesync/issues/1140)
|
||||
- [Self-hosted LiveSync issue 1147](https://github.com/vrtmrz/obsidian-livesync/issues/1147)
|
||||
@@ -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.
|
||||
+13
-1
@@ -443,6 +443,14 @@ Setting key: P2P_relays
|
||||
|
||||
The Nostr-compatible WebSocket relay URL or URLs used for peer discovery and WebRTC connection negotiation. Multiple URLs can be separated by commas. A signalling relay does not store or transfer Vault contents. See [How peer-to-peer synchronisation works](p2p.md).
|
||||
|
||||
The P2P Setup connection test does not interrupt an active P2P room. When the
|
||||
active relay set already covers the requested URLs, the test observes that
|
||||
active signalling transport. If the test would add a relay while P2P is
|
||||
active, it asks you to use the active relay settings or disconnect P2P first.
|
||||
When P2P is idle, the test opens and disposes a short-lived signalling trial.
|
||||
This check does not prove peer discovery, room credentials against another
|
||||
device, or a TURN or WebRTC data path.
|
||||
|
||||
#### Group ID
|
||||
|
||||
Setting key: P2P_roomID
|
||||
@@ -1105,7 +1113,11 @@ Purge all download/upload cache.
|
||||
|
||||
#### Fresh Start Wipe
|
||||
|
||||
Delete all data on the remote server.
|
||||
Delete all data on the remote server in batches; this operation is not
|
||||
transactional. Stop all synchronising devices before starting. If the
|
||||
operation is interrupted or reports failure, keep them stopped, rerun Fresh
|
||||
Start Wipe, and then use **Overwrite Server Data with This Device's Files**
|
||||
from an authoritative Vault.
|
||||
|
||||
### 6. Garbage Collection V3 (CouchDB only)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Enabling Hidden File Sync requires an initialisation direction:
|
||||
## Review the file selection
|
||||
|
||||
1. Open Self-hosted LiveSync settings.
|
||||
2. Open `Setup`, find `Enable extra and advanced features`, and enable `Advanced features`.
|
||||
2. Open `General Settings` → `Extra menus`, and enable `Advanced features`.
|
||||
|
||||

|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.21",
|
||||
"version": "1.0.22",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
Generated
+22
-2898
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.21",
|
||||
"version": "1.0.22",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
@@ -165,7 +165,6 @@
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8",
|
||||
"webdriverio": "^9.27.0",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -178,7 +177,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.19",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.20",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
|
||||
+39
-20
@@ -1,7 +1,11 @@
|
||||
import { LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
|
||||
import type PouchDB from "pouchdb-core";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import type { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
type HasSettings,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type EntryDoc,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { __$checkInstanceBinding } from "@vrtmrz/livesync-commonlib/compat/dev/checks";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||
@@ -11,17 +15,13 @@ import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces
|
||||
import type { LiveSyncLocalDBEnv } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
|
||||
import type { LiveSyncCouchDBReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import type { CheckPointInfo } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncTypes";
|
||||
import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicatorEnv";
|
||||
import type { LiveSyncReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||
import type { ReplicatorInstance } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { useTargetFilters } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/targetFilter";
|
||||
import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { AbstractModule } from "./modules/AbstractModule";
|
||||
import { ModulePeriodicProcess } from "./modules/core/ModulePeriodicProcess";
|
||||
import { ModuleReplicator } from "./modules/core/ModuleReplicator";
|
||||
import { ModuleReplicatorCouchDB } from "./modules/core/ModuleReplicatorCouchDB";
|
||||
import { ModuleReplicatorMinIO } from "./modules/core/ModuleReplicatorMinIO";
|
||||
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
|
||||
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
|
||||
@@ -30,6 +30,16 @@ import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interface
|
||||
import { ModuleBasicMenu } from "./modules/essential/ModuleBasicMenu";
|
||||
import { usePrepareDatabaseForUse } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/prepareDatabaseForUse";
|
||||
import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils.type";
|
||||
import { useReplicationScheduling, type ReplicationSchedulingControl } from "./serviceFeatures/replicationScheduling";
|
||||
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
|
||||
import { useReplicationFeature } from "./serviceFeatures/replication";
|
||||
|
||||
/** Focused views returned by serviceFeatures which the host may consume during composition. */
|
||||
export interface LiveSyncCoreFeatureViews {
|
||||
readonly replicationScheduling: ReplicationSchedulingControl;
|
||||
}
|
||||
|
||||
type CompatibilityReplicatorView = ReplicatorInstance & Partial<LiveSyncAbstractReplicator>;
|
||||
|
||||
export class LiveSyncBaseCore<
|
||||
T extends ServiceContext = ServiceContext,
|
||||
@@ -37,8 +47,6 @@ export class LiveSyncBaseCore<
|
||||
>
|
||||
implements
|
||||
LiveSyncLocalDBEnv,
|
||||
LiveSyncReplicatorEnv,
|
||||
LiveSyncJournalReplicatorEnv,
|
||||
LiveSyncCouchDBReplicatorEnv,
|
||||
HasSettings<ObsidianLiveSyncSettings>
|
||||
{
|
||||
@@ -74,18 +82,22 @@ export class LiveSyncBaseCore<
|
||||
) => ServiceModules,
|
||||
extraModuleInitialiser: (core: LiveSyncBaseCore<T, TCommands>) => AbstractModule[],
|
||||
addOnsInitialiser: (core: LiveSyncBaseCore<T, TCommands>) => TCommands[],
|
||||
featuresInitialiser: (core: LiveSyncBaseCore<T, TCommands>) => void
|
||||
featuresInitialiser: (core: LiveSyncBaseCore<T, TCommands>, coreFeatureViews: LiveSyncCoreFeatureViews) => void
|
||||
) {
|
||||
this._services = serviceHub;
|
||||
this.registerReplicatorProviders();
|
||||
this._serviceModules = serviceModuleInitialiser(this, serviceHub);
|
||||
const extraModules = extraModuleInitialiser(this);
|
||||
this.registerModules(extraModules);
|
||||
this.initialiseServiceFeatures();
|
||||
featuresInitialiser(this);
|
||||
const coreFeatureViews = this.initialiseServiceFeatures();
|
||||
featuresInitialiser(this, coreFeatureViews);
|
||||
const addOns = addOnsInitialiser(this);
|
||||
for (const addOn of addOns) {
|
||||
this._registerAddOn(addOn);
|
||||
}
|
||||
// Register host features and add-ons before replication, then bind
|
||||
// legacy modules so lifecycle handlers observe the required order.
|
||||
useReplicationFeature(this);
|
||||
this.bindModuleFunctions();
|
||||
}
|
||||
/**
|
||||
@@ -136,14 +148,17 @@ export class LiveSyncBaseCore<
|
||||
this.modules.push(module);
|
||||
}
|
||||
|
||||
/** Compose the current central providers before any lifecycle event can acquire one. */
|
||||
private registerReplicatorProviders() {
|
||||
this.services.replicator.registerReplicatorProviderDefinitions(
|
||||
createCentralReplicatorProviderDefinitions(this)
|
||||
);
|
||||
}
|
||||
|
||||
public registerModules(extraModules: AbstractModule[] = []) {
|
||||
this._registerModule(new ModuleLiveSyncMain(this));
|
||||
this._registerModule(new ModuleConflictChecker(this));
|
||||
this._registerModule(new ModuleReplicatorMinIO(this));
|
||||
this._registerModule(new ModuleReplicatorCouchDB(this));
|
||||
this._registerModule(new ModuleReplicator(this));
|
||||
this._registerModule(new ModuleConflictResolver(this));
|
||||
this._registerModule(new ModulePeriodicProcess(this));
|
||||
this._registerModule(new ModuleResolvingMismatchedTweaks(this));
|
||||
this._registerModule(new ModuleBasicMenu(this));
|
||||
|
||||
@@ -223,10 +238,11 @@ export class LiveSyncBaseCore<
|
||||
}
|
||||
|
||||
/**
|
||||
* @obsolete Use services.replication.getActiveReplicator instead. Get the active replicator instance. Note that there can be multiple replicators, but only one can be active at a time.
|
||||
* @obsolete Use the provider context or a focused service operation instead.
|
||||
* Provider-specific members on this compatibility view are optional.
|
||||
*/
|
||||
get replicator() {
|
||||
return this.services.replicator.getActiveReplicator()!;
|
||||
get replicator(): CompatibilityReplicatorView {
|
||||
return this.services.replicator.getActiveReplicator() as CompatibilityReplicatorView;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,12 +289,15 @@ export class LiveSyncBaseCore<
|
||||
* Initialise ServiceFeatures.
|
||||
* (Please refer `serviceFeatures` for more details)
|
||||
*/
|
||||
initialiseServiceFeatures() {
|
||||
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
|
||||
useTargetFilters(this);
|
||||
// enable target filter feature.
|
||||
usePrepareDatabaseForUse(this);
|
||||
// Migration to multiple remote configurations
|
||||
useRemoteConfigurationMigration(this);
|
||||
return Object.freeze({
|
||||
replicationScheduling: useReplicationScheduling(this),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,9 @@ livesync-cli [database-path] [command] [args...]
|
||||
- `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.
|
||||
- Temporary values used to suspend synchronisation or select a remote for one command are never written.
|
||||
- `--compat-remote-admin-exit-zero`: Preserve the former zero exit code when `mark-resolved`, `lock-remote`, or `unlock-remote` returns a provider verification failure.
|
||||
- Without this option, those commands return a non-zero exit code when verification fails.
|
||||
- Invalid arguments, unknown remote IDs, and errors thrown while activating or mutating the remote remain errors with or without this option.
|
||||
|
||||
### Commands
|
||||
|
||||
@@ -96,6 +99,8 @@ livesync-cli [database-path] [command] [args...]
|
||||
- `remote-status [remote-id]`: Show remote database status.
|
||||
- `init-settings [file]`: Create a default settings file.
|
||||
|
||||
Remote-administration commands verify the resulting milestone state through the selected provider. The existing `[Verification]` lines remain suitable for scripts which inspect command output, while the default exit code now reflects whether that verification succeeded.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
@@ -338,6 +343,8 @@ Options:
|
||||
--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
|
||||
--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
|
||||
--help, -h Show this help message
|
||||
|
||||
Commands:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||
isCentralRemoteAdministrationVerified,
|
||||
type CentralRemoteAdministrationAction,
|
||||
type CentralRemoteAdministrationResult,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { writeStderrLine } from "@/apps/cli/cliOutput";
|
||||
import type { CLICommand, CLICommandContext, CLIOptions } from "./types";
|
||||
|
||||
const CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND = Object.freeze({
|
||||
"mark-resolved": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||
"lock-remote": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
"unlock-remote": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||
} as const satisfies Partial<Record<CLICommand, CentralRemoteAdministrationAction>>);
|
||||
|
||||
export type CentralRemoteAdministrationCommand = keyof typeof CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND;
|
||||
|
||||
/** Return whether a CLI command belongs to the central-remote administration category. */
|
||||
export function isCentralRemoteAdministrationCommand(
|
||||
command: CLICommand
|
||||
): command is CentralRemoteAdministrationCommand {
|
||||
return Object.prototype.hasOwnProperty.call(CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND, command);
|
||||
}
|
||||
|
||||
function detailMessage(detail: unknown): string {
|
||||
return detail instanceof Error ? detail.message : String(detail);
|
||||
}
|
||||
|
||||
function assertNeverCentralRemoteAdministrationFailureReason(reason: never): never {
|
||||
throw new Error(`Unexpected central remote administration failure reason: ${String(reason)}`);
|
||||
}
|
||||
|
||||
function reportMilestoneObservation(
|
||||
standardIo: StandardIo,
|
||||
observation: Extract<
|
||||
CentralRemoteAdministrationResult["observation"],
|
||||
{ kind: typeof CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE }
|
||||
>
|
||||
): void {
|
||||
standardIo.writeStderr(`[Verification] Remote Database: ${observation.locked ? "LOCKED" : "UNLOCKED"}\n`);
|
||||
standardIo.writeStderr(
|
||||
`[Verification] Current Device Node ID (${observation.nodeId}): ${observation.accepted ? "ACCEPTED" : "NOT ACCEPTED"}\n`
|
||||
);
|
||||
}
|
||||
|
||||
/** Map typed provider observations to the CLI's established verification output. */
|
||||
function reportCentralRemoteAdministrationResult(
|
||||
standardIo: StandardIo,
|
||||
result: CentralRemoteAdministrationResult
|
||||
): void {
|
||||
if (result.observation?.kind === CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE) {
|
||||
reportMilestoneObservation(standardIo, result.observation);
|
||||
return;
|
||||
}
|
||||
if (isCentralRemoteAdministrationVerified(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = result.reason;
|
||||
switch (reason) {
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR:
|
||||
standardIo.writeStderr("[Verification] No active replicator found\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED:
|
||||
standardIo.writeStderr(
|
||||
`[Verification] Failed to connect to the configured remote: ${detailMessage(result.detail)}\n`
|
||||
);
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.ACTIVE_CONFIGURATION_MISMATCH:
|
||||
standardIo.writeStderr(
|
||||
"[Verification] The active remote configuration changed before remote administration could begin.\n"
|
||||
);
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND:
|
||||
standardIo.writeStderr("[Verification] Milestone document not found on remote.\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED:
|
||||
standardIo.writeStderr(
|
||||
`[Verification] Failed to fetch milestone document: ${detailMessage(result.detail)}\n`
|
||||
);
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE:
|
||||
standardIo.writeStderr("[Verification] Failed to initialise the current device identity.\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_IMPLEMENTED:
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE:
|
||||
standardIo.writeStderr("[Verification] Remote administration is unavailable for this provider.\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH:
|
||||
standardIo.writeStderr("[Verification] The requested remote state was not observed.\n");
|
||||
return;
|
||||
default:
|
||||
return assertNeverCentralRemoteAdministrationFailureReason(reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one provider-owned mutation and map its typed verification to CLI exit policy.
|
||||
* Mutation exceptions deliberately escape this boundary.
|
||||
*/
|
||||
export async function runCentralRemoteAdministrationCommand(
|
||||
options: CLIOptions,
|
||||
context: CLICommandContext,
|
||||
command: CentralRemoteAdministrationCommand
|
||||
): Promise<boolean> {
|
||||
const id = options.commandArgs[0]?.trim();
|
||||
if (id) {
|
||||
let switched = false;
|
||||
await context.core.services.setting.updateSettings((currentSettings) => {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
if (activated) {
|
||||
switched = true;
|
||||
return activated;
|
||||
}
|
||||
return currentSettings;
|
||||
}, false);
|
||||
|
||||
if (!switched) {
|
||||
context.core.services.context.standardIo.writeStderr(
|
||||
`[Info] Failed to temporarily activate remote configuration: ${id}\n`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
await context.core.services.control.applySettings();
|
||||
}
|
||||
|
||||
writeStderrLine(context.core.services.context.standardIo, `[Command] ${command}${id ? ` ${id}` : ""}`);
|
||||
const action = CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND[command];
|
||||
const result = await context.core.services.replicator.runCentralRemoteAdministration({ action });
|
||||
reportCentralRemoteAdministrationResult(context.core.services.context.standardIo, result);
|
||||
return isCentralRemoteAdministrationVerified(result) || options.compatRemoteAdminExitZero === true;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
|
||||
@@ -38,7 +39,7 @@ function createCoreMock() {
|
||||
currentSettings: vi.fn(() => ({ liveSync: true, syncOnStart: false })),
|
||||
},
|
||||
replication: {
|
||||
replicate: vi.fn(async () => true),
|
||||
replicateUnattended: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
appLifecycle: {
|
||||
onUnload: {
|
||||
@@ -87,6 +88,17 @@ const baseContext = {
|
||||
},
|
||||
} as any;
|
||||
|
||||
function createDaemonContext(core: ReturnType<typeof createCoreMock>) {
|
||||
return {
|
||||
...baseContext,
|
||||
core,
|
||||
replicationScheduling: {
|
||||
setExternalPollingMode: vi.fn(),
|
||||
markInitialOneShotSatisfied: vi.fn(),
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("daemon command", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -101,7 +113,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(offlineScanner.performFullScan).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -110,7 +122,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(false);
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
@@ -120,9 +132,11 @@ describe("daemon command", () => {
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
const context = createDaemonContext(core);
|
||||
await runCommand(makeDaemonOptions(30), context);
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
expect(context.replicationScheduling.setExternalPollingMode).toHaveBeenCalledWith(true);
|
||||
// Interval should be in milliseconds (30s → 30000ms)
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30000);
|
||||
});
|
||||
@@ -131,7 +145,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(10), createDaemonContext(core));
|
||||
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ suspendFileWatching: false }),
|
||||
@@ -144,7 +158,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -164,7 +178,7 @@ describe("daemon command", () => {
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(result).toBe(true);
|
||||
const warningCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
|
||||
@@ -182,7 +196,7 @@ describe("daemon command", () => {
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
const warningCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
|
||||
([chunk]: [string | Uint8Array]) =>
|
||||
@@ -194,37 +208,50 @@ describe("daemon command", () => {
|
||||
it("calls replicate before performFullScan", async () => {
|
||||
const core = createCoreMock();
|
||||
const callOrder: string[] = [];
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => {
|
||||
callOrder.push("replicate");
|
||||
return true;
|
||||
return { status: "completed" as const };
|
||||
});
|
||||
vi.mocked(offlineScanner.performFullScan).mockImplementation(async () => {
|
||||
callOrder.push("performFullScan");
|
||||
return true;
|
||||
});
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const context = createDaemonContext(core);
|
||||
await runCommand(makeDaemonOptions(), context);
|
||||
|
||||
expect(callOrder).toEqual(["replicate", "performFullScan"]);
|
||||
expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
expect(context.replicationScheduling.markInitialOneShotSatisfied).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns false when initial replication fails", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replication.replicate = vi.fn(async () => false);
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => ({
|
||||
status: "failed" as const,
|
||||
error: new Error("initial replication failed"),
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockClear();
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(result).toBe(false);
|
||||
// performFullScan should NOT have been called
|
||||
expect(offlineScanner.performFullScan).not.toHaveBeenCalled();
|
||||
expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
});
|
||||
|
||||
it("polling mode: registers onUnload handler that clears timeout", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(10), createDaemonContext(core));
|
||||
|
||||
// onUnload handler should have been registered
|
||||
expect(core.services.appLifecycle.onUnload.addHandler).toHaveBeenCalledTimes(1);
|
||||
@@ -242,17 +269,17 @@ describe("daemon command", () => {
|
||||
|
||||
// startup replicate (call 1) succeeds; poll calls 2–7 fail; call 8 succeeds.
|
||||
let callCount = 0;
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return true; // initial startup replicate
|
||||
if (callCount === 1) return { status: "completed" as const }; // initial startup replicate
|
||||
if (callCount <= 7) throw new Error("network failure");
|
||||
return true; // recovery
|
||||
return { status: "completed" as const }; // recovery
|
||||
});
|
||||
|
||||
const baseMs = 30 * 1000;
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(30), createDaemonContext(core));
|
||||
|
||||
// After runCommand returns the first setTimeout has been scheduled.
|
||||
// setTimeoutSpy.mock.calls[0] is the initial schedule (baseMs).
|
||||
@@ -297,14 +324,14 @@ describe("daemon command", () => {
|
||||
|
||||
// Make replicate succeed on the initial call (startup), then fail on the poll.
|
||||
let callCount = 0;
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return true; // startup replicate
|
||||
if (callCount === 1) return { status: "completed" as const }; // startup replicate
|
||||
throw new Error("network failure");
|
||||
});
|
||||
|
||||
const intervalMs = 30 * 1000;
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(30), createDaemonContext(core));
|
||||
|
||||
// Advance time to trigger the first poll callback and flush its async work.
|
||||
await vi.advanceTimersByTimeAsync(intervalMs);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { P2P_DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||
import { getPeerConnectionStats } from "@vrtmrz/livesync-commonlib/compat/rpc/transports/DiagRTCPeerConnections.utils";
|
||||
import type { P2PPeerConnectionMetrics, P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { fsPromises } from "@vrtmrz/livesync-commonlib/node";
|
||||
|
||||
type CLIP2PPeer = {
|
||||
@@ -12,17 +11,13 @@ type CLIP2PPeer = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
type CandidateSummary = {
|
||||
id: string;
|
||||
candidateType: string;
|
||||
protocol: string;
|
||||
relayProtocol: string;
|
||||
};
|
||||
type CLIP2PService = Pick<P2PServiceViews, "transportLifecycle" | "peerDirectory" | "targetedTransfer" | "diagnostics">;
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => compatGlobal.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** Parse a CLI timeout expressed as a finite, non-negative number of seconds. */
|
||||
export function parseTimeoutSeconds(value: string, commandName: string): number {
|
||||
const timeoutSec = Number(value);
|
||||
if (!Number.isFinite(timeoutSec) || timeoutSec < 0) {
|
||||
@@ -43,35 +38,36 @@ function validateP2PSettings(core: LiveSyncBaseCore<ServiceContext, never>) {
|
||||
settings.P2P_IsHeadless = true;
|
||||
}
|
||||
|
||||
async function createReplicator(core: LiveSyncBaseCore<ServiceContext, never>): Promise<LiveSyncTrysteroReplicator> {
|
||||
function requireP2PService(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
service: CLIP2PService | undefined
|
||||
): CLIP2PService {
|
||||
validateP2PSettings(core);
|
||||
const replicator = await core.services.replicator.getNewReplicator();
|
||||
if (!replicator) {
|
||||
throw new Error("Failed to create replicator instance. Ensure P2P is enabled in settings.");
|
||||
if (!service) {
|
||||
throw new Error("P2P service is not available. Ensure the P2P feature was composed for this CLI process.");
|
||||
}
|
||||
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
|
||||
throw new Error("Unexpected replicator type. Expected LiveSyncTrysteroReplicator.");
|
||||
}
|
||||
return replicator;
|
||||
return service;
|
||||
}
|
||||
|
||||
function getSortedPeers(replicator: LiveSyncTrysteroReplicator): CLIP2PPeer[] {
|
||||
return [...replicator.knownAdvertisements]
|
||||
function getSortedPeers(service: Pick<P2PServiceViews, "peerDirectory">): CLIP2PPeer[] {
|
||||
return [...service.peerDirectory.getPeers()]
|
||||
.map((peer) => ({ peerId: peer.peerId, name: peer.name }))
|
||||
.sort((a, b) => a.peerId.localeCompare(b.peerId));
|
||||
}
|
||||
|
||||
/** Connect for a bounded discovery interval, return a stable peer ordering, and disconnect. */
|
||||
export async function collectPeers(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
p2pService: CLIP2PService | undefined,
|
||||
timeoutSec: number
|
||||
): Promise<CLIP2PPeer[]> {
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
const service = requireP2PService(core, p2pService);
|
||||
await service.transportLifecycle.connect();
|
||||
try {
|
||||
await delay(timeoutSec * 1000);
|
||||
return getSortedPeers(replicator);
|
||||
return getSortedPeers(service);
|
||||
} finally {
|
||||
await replicator.close();
|
||||
await service.transportLifecycle.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,32 +86,8 @@ function resolvePeer(peers: CLIP2PPeer[], peerToken: string): CLIP2PPeer | undef
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getReportValue<T extends string | number>(
|
||||
report: Record<string, unknown> | undefined,
|
||||
key: string
|
||||
): T | "unknown" {
|
||||
const value = report?.[key];
|
||||
return typeof value === "string" || typeof value === "number" ? (value as T) : "unknown";
|
||||
}
|
||||
|
||||
function summariseCandidate(reports: unknown[], candidateId: string): CandidateSummary | undefined {
|
||||
if (candidateId === "unknown") {
|
||||
return undefined;
|
||||
}
|
||||
const report = reports.map((r) => r as Record<string, unknown>).find((r) => r.id === candidateId);
|
||||
if (!report) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: candidateId,
|
||||
candidateType: getReportValue<string>(report, "candidateType"),
|
||||
protocol: getReportValue<string>(report, "protocol"),
|
||||
relayProtocol: getReportValue<string>(report, "relayProtocol"),
|
||||
};
|
||||
}
|
||||
|
||||
async function writePeerConnectionStatsIfRequested(
|
||||
replicator: LiveSyncTrysteroReplicator,
|
||||
service: Pick<P2PServiceViews, "diagnostics">,
|
||||
peer: CLIP2PPeer
|
||||
): Promise<void> {
|
||||
const outputPath = process.env.LIVESYNC_P2P_STATS_JSONL?.trim();
|
||||
@@ -123,21 +95,30 @@ async function writePeerConnectionStatsIfRequested(
|
||||
return;
|
||||
}
|
||||
|
||||
const peerConnection = replicator.rawHost?.room?.getPeers()[peer.peerId];
|
||||
const stats = peerConnection ? await getPeerConnectionStats(`cli-p2p-${peer.peerId}`, peerConnection) : undefined;
|
||||
const localCandidate = summariseCandidate(stats?.reports ?? [], stats?.localCandidateId ?? "unknown");
|
||||
const remoteCandidate = summariseCandidate(stats?.reports ?? [], stats?.remoteCandidateId ?? "unknown");
|
||||
const stats = await service.diagnostics.getPeerConnectionMetrics(peer.peerId);
|
||||
const payload = createPeerConnectionStatsPayload(peer, stats, new Date().toISOString());
|
||||
await fsPromises.appendFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8");
|
||||
}
|
||||
|
||||
/** Build the stable JSONL record consumed by the P2P benchmark harnesses. */
|
||||
export function createPeerConnectionStatsPayload(
|
||||
peer: CLIP2PPeer,
|
||||
stats: P2PPeerConnectionMetrics | undefined,
|
||||
generatedAt: string
|
||||
) {
|
||||
const localCandidate = stats?.localCandidate;
|
||||
const remoteCandidate = stats?.remoteCandidate;
|
||||
const selectedPath =
|
||||
localCandidate && remoteCandidate
|
||||
? `${localCandidate.candidateType}<->${remoteCandidate.candidateType}`
|
||||
: "unknown";
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedAt,
|
||||
command: "p2p-sync",
|
||||
peerId: peer.peerId,
|
||||
peerName: peer.name,
|
||||
candidatePathCollected: !!stats?.selectedPair,
|
||||
candidatePathCollected: stats?.selectedPairPresent ?? false,
|
||||
selectedPath,
|
||||
selectedPair: stats
|
||||
? {
|
||||
@@ -155,23 +136,25 @@ async function writePeerConnectionStatsIfRequested(
|
||||
localCandidate,
|
||||
remoteCandidate,
|
||||
};
|
||||
await fsPromises.appendFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8");
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Resolve one peer token, complete pull then push, and disconnect on every settlement. */
|
||||
export async function syncWithPeer(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
p2pService: CLIP2PService | undefined,
|
||||
peerToken: string,
|
||||
timeoutSec: number
|
||||
): Promise<CLIP2PPeer> {
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
const service = requireP2PService(core, p2pService);
|
||||
await service.transportLifecycle.connect();
|
||||
try {
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
const start = Date.now();
|
||||
let targetPeer: CLIP2PPeer | undefined;
|
||||
|
||||
while (Date.now() - start <= timeoutMs) {
|
||||
const peers = getSortedPeers(replicator);
|
||||
const peers = getSortedPeers(service);
|
||||
targetPeer = resolvePeer(peers, peerToken);
|
||||
if (targetPeer) {
|
||||
break;
|
||||
@@ -183,11 +166,14 @@ export async function syncWithPeer(
|
||||
throw new Error(`Peer '${peerToken}' was not found within ${timeoutSec} seconds`);
|
||||
}
|
||||
|
||||
const pullResult = await replicator.replicateFrom(targetPeer.peerId, false);
|
||||
const pullResult = await service.targetedTransfer.pullFromPeer(targetPeer.peerId, { showNotice: false });
|
||||
if (pullResult && "error" in pullResult && pullResult.error) {
|
||||
throw pullResult.error instanceof Error ? pullResult.error : LiveSyncError.fromError(pullResult.error);
|
||||
}
|
||||
const pushResult = await replicator.requestSynchroniseToPeer(targetPeer.peerId);
|
||||
if (!pullResult || pullResult.status !== "completed") {
|
||||
throw LiveSyncError.fromError("P2P sync failed while pulling from peer");
|
||||
}
|
||||
const pushResult = await service.targetedTransfer.requestPushToPeer(targetPeer.peerId);
|
||||
if (!pushResult || pushResult.ok !== true) {
|
||||
const err: unknown = pushResult && "error" in pushResult ? pushResult.error : undefined;
|
||||
throw err instanceof Error
|
||||
@@ -195,15 +181,19 @@ export async function syncWithPeer(
|
||||
: LiveSyncError.fromError(err ?? "P2P sync failed while requesting remote sync");
|
||||
}
|
||||
|
||||
await writePeerConnectionStatsIfRequested(replicator, targetPeer);
|
||||
await writePeerConnectionStatsIfRequested(service, targetPeer);
|
||||
return targetPeer;
|
||||
} finally {
|
||||
await replicator.close();
|
||||
await service.transportLifecycle.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export async function openP2PHost(core: LiveSyncBaseCore<ServiceContext, never>): Promise<LiveSyncTrysteroReplicator> {
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
return replicator;
|
||||
/** Connect the headless P2P host and transfer transport ownership to the caller. */
|
||||
export async function openP2PHost(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
p2pService: CLIP2PService | undefined
|
||||
): Promise<CLIP2PService> {
|
||||
const service = requireP2PService(core, p2pService);
|
||||
await service.transportLifecycle.connect();
|
||||
return service;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseTimeoutSeconds } from "./p2p";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { collectPeers, createPeerConnectionStatsPayload, parseTimeoutSeconds, syncWithPeer } from "./p2p";
|
||||
|
||||
function createCore() {
|
||||
const settings = { P2P_Enabled: true, P2P_AppID: "app-id", P2P_IsHeadless: false };
|
||||
return {
|
||||
services: {
|
||||
setting: { currentSettings: () => settings },
|
||||
replicator: { getNewReplicator: vi.fn(() => Promise.reject(new Error("must not be called"))) },
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function createP2PService() {
|
||||
const connect = vi.fn(async () => undefined);
|
||||
const disconnect = vi.fn(async () => undefined);
|
||||
const pullFromPeer = vi.fn(async () => ({ status: "completed" as const, ok: true as const }));
|
||||
const requestPushToPeer = vi.fn(async () => ({ status: "completed" as const, ok: true as const }));
|
||||
return {
|
||||
service: {
|
||||
transportLifecycle: { isConnected: false, connect, disconnect },
|
||||
peerDirectory: {
|
||||
getPeers: () => [{ peerId: "peer-a", name: "Peer A", platform: "test" }],
|
||||
},
|
||||
targetedTransfer: {
|
||||
pullFromPeer,
|
||||
requestPushToPeer,
|
||||
synchroniseWithPeer: vi.fn(),
|
||||
},
|
||||
diagnostics: { requestStatus: vi.fn(), getPeerConnectionMetrics: vi.fn() },
|
||||
},
|
||||
connect,
|
||||
disconnect,
|
||||
pullFromPeer,
|
||||
requestPushToPeer,
|
||||
};
|
||||
}
|
||||
|
||||
describe("p2p command helpers", () => {
|
||||
it("accepts non-negative timeout", () => {
|
||||
@@ -15,4 +50,98 @@ describe("p2p command helpers", () => {
|
||||
"p2p-sync requires a non-negative timeout in seconds"
|
||||
);
|
||||
});
|
||||
|
||||
it("collects peers through service views without acquiring a concrete replicator", async () => {
|
||||
const { service, connect, disconnect } = createP2PService();
|
||||
|
||||
await expect(collectPeers(createCore(), service as never, 0)).resolves.toEqual([
|
||||
{ peerId: "peer-a", name: "Peer A" },
|
||||
]);
|
||||
expect(connect).toHaveBeenCalledOnce();
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("synchronises through the targeted-transfer view", async () => {
|
||||
const { service, pullFromPeer, requestPushToPeer } = createP2PService();
|
||||
|
||||
await expect(syncWithPeer(createCore(), service as never, "peer-a", 0)).resolves.toEqual({
|
||||
peerId: "peer-a",
|
||||
name: "Peer A",
|
||||
});
|
||||
expect(pullFromPeer).toHaveBeenCalledWith("peer-a", { showNotice: false });
|
||||
expect(requestPushToPeer).toHaveBeenCalledWith("peer-a");
|
||||
});
|
||||
|
||||
it("rejects a cancelled pull without requesting a peer push", async () => {
|
||||
const { service, disconnect, pullFromPeer, requestPushToPeer } = createP2PService();
|
||||
pullFromPeer.mockResolvedValue({ status: "cancelled" } as never);
|
||||
|
||||
await expect(syncWithPeer(createCore(), service as never, "peer-a", 0)).rejects.toBeDefined();
|
||||
|
||||
expect(requestPushToPeer).not.toHaveBeenCalled();
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves the benchmark diagnostics JSONL contract", () => {
|
||||
expect(
|
||||
createPeerConnectionStatsPayload(
|
||||
{ peerId: "peer-a", name: "Peer A" },
|
||||
{
|
||||
selectedPairPresent: true,
|
||||
selectedPairId: "pair-1",
|
||||
state: "succeeded",
|
||||
currentRoundTripTime: 0.01,
|
||||
totalRoundTripTime: 0.1,
|
||||
requestsSent: 3,
|
||||
responsesReceived: 3,
|
||||
packetsDiscardedOnSend: 0,
|
||||
bytesSent: 100,
|
||||
bytesReceived: 200,
|
||||
localCandidate: {
|
||||
id: "local-1",
|
||||
candidateType: "host",
|
||||
protocol: "udp",
|
||||
relayProtocol: "unknown",
|
||||
},
|
||||
remoteCandidate: {
|
||||
id: "remote-1",
|
||||
candidateType: "relay",
|
||||
protocol: "udp",
|
||||
relayProtocol: "udp",
|
||||
},
|
||||
},
|
||||
"2026-08-27T00:00:00.000Z"
|
||||
)
|
||||
).toEqual({
|
||||
generatedAt: "2026-08-27T00:00:00.000Z",
|
||||
command: "p2p-sync",
|
||||
peerId: "peer-a",
|
||||
peerName: "Peer A",
|
||||
candidatePathCollected: true,
|
||||
selectedPath: "host<->relay",
|
||||
selectedPair: {
|
||||
id: "pair-1",
|
||||
state: "succeeded",
|
||||
currentRoundTripTime: 0.01,
|
||||
totalRoundTripTime: 0.1,
|
||||
requestsSent: 3,
|
||||
responsesReceived: 3,
|
||||
packetsDiscardedOnSend: 0,
|
||||
bytesSent: 100,
|
||||
bytesReceived: 200,
|
||||
},
|
||||
localCandidate: {
|
||||
id: "local-1",
|
||||
candidateType: "host",
|
||||
protocol: "udp",
|
||||
relayProtocol: "unknown",
|
||||
},
|
||||
remoteCandidate: {
|
||||
id: "remote-1",
|
||||
candidateType: "relay",
|
||||
protocol: "udp",
|
||||
relayProtocol: "udp",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,8 @@ import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/AP
|
||||
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
MILESTONE_DOCID,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
type EntryMilestoneInfo,
|
||||
type EntryDoc,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
@@ -23,71 +19,26 @@ import { performFullScan } from "@vrtmrz/livesync-commonlib/compat/serviceFeatur
|
||||
import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
isReplicationCompleted,
|
||||
NO_INTERACTION,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
|
||||
import {
|
||||
isCentralRemoteAdministrationCommand,
|
||||
runCentralRemoteAdministrationCommand,
|
||||
} from "./centralRemoteAdministration";
|
||||
|
||||
function redactConnectionString(uri: string): string {
|
||||
return uri.replace(/\/\/([^@/]+)@/u, "//***@");
|
||||
}
|
||||
|
||||
async function verifyRemoteState(
|
||||
core: CLICommandContext["core"],
|
||||
settings: ObsidianLiveSyncSettings
|
||||
): Promise<boolean> {
|
||||
const { standardIo } = core.services.context;
|
||||
const replicator = core.services.replicator.getActiveReplicator();
|
||||
if (!replicator) {
|
||||
standardIo.writeStderr("[Verification] No active replicator found\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!replicator.nodeid) {
|
||||
await replicator.initializeDatabaseForReplication();
|
||||
}
|
||||
|
||||
try {
|
||||
let milestone: EntryMilestoneInfo | false | undefined = undefined;
|
||||
if (settings.remoteType === REMOTE_COUCHDB) {
|
||||
const dbRet = await (replicator as LiveSyncCouchDBReplicator).connectRemoteCouchDBWithSetting(
|
||||
settings,
|
||||
false,
|
||||
true
|
||||
);
|
||||
if (typeof dbRet === "string") {
|
||||
standardIo.writeStderr(`[Verification] Failed to connect to remote CouchDB: ${dbRet}\n`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
milestone = await dbRet.db.get(MILESTONE_DOCID);
|
||||
} finally {
|
||||
await dbRet.db.close();
|
||||
}
|
||||
} else if (settings.remoteType === REMOTE_MINIO) {
|
||||
milestone = await (replicator as LiveSyncJournalReplicator).client.downloadJson("_00000000-milestone.json");
|
||||
}
|
||||
|
||||
if (milestone) {
|
||||
const isLocked = !!milestone.locked;
|
||||
const isAccepted = !!milestone.accepted_nodes?.includes(replicator.nodeid);
|
||||
standardIo.writeStderr(`[Verification] Remote Database: ${isLocked ? "LOCKED" : "UNLOCKED"}\n`);
|
||||
standardIo.writeStderr(
|
||||
`[Verification] Current Device Node ID (${replicator.nodeid}): ${isAccepted ? "ACCEPTED" : "NOT ACCEPTED"}\n`
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
standardIo.writeStderr("[Verification] Milestone document not found on remote.\n");
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
standardIo.writeStderr(`[Verification] Failed to fetch milestone document: ${message}\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> {
|
||||
const { databasePath, core, settingsPath } = context;
|
||||
const { databasePath, core, replicationScheduling, settingsPath } = context;
|
||||
const { standardIo } = core.services.context;
|
||||
const vaultPath = context.vaultPath || databasePath;
|
||||
|
||||
@@ -95,19 +46,28 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
if (options.command === "daemon") {
|
||||
const log = (msg: unknown) => writeStderrLine(standardIo, `[Daemon] ${String(msg)}`);
|
||||
|
||||
// The daemon owns its own recurring poller. Suppress the application
|
||||
// resume starter and generic periodic timer before restoring settings.
|
||||
replicationScheduling.setExternalPollingMode(!!options.interval);
|
||||
|
||||
// Skip the config mismatch dialog — the daemon cannot resolve it interactively
|
||||
// and the default "Dismiss" action would block replication. The daemon should
|
||||
// accept whatever configuration the remote has.
|
||||
await core.services.setting.applyPartial({ disableCheckingConfigMismatch: true }, true);
|
||||
|
||||
// 1. Replicate CouchDB → local PouchDB so the mirror scan has content to work with.
|
||||
log("Replicating from CouchDB...");
|
||||
const replResult = await core.services.replication.replicate(true);
|
||||
if (!replResult) {
|
||||
writeStderrLine(standardIo, "[Daemon] Initial CouchDB replication failed, cannot continue");
|
||||
// 1. Replicate the configured remote into the local database so the
|
||||
// mirror scan has content to work with.
|
||||
log("Replicating from remote...");
|
||||
const replResult = await core.services.replication.replicateUnattended({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
if (!isReplicationCompleted(replResult)) {
|
||||
writeStderrLine(standardIo, "[Daemon] Initial replication failed, cannot continue");
|
||||
return false;
|
||||
}
|
||||
log("CouchDB replication complete");
|
||||
replicationScheduling.markInitialOneShotSatisfied();
|
||||
log("Initial replication complete");
|
||||
|
||||
// 2. Mirror scan to reconcile PouchDB ↔ local filesystem.
|
||||
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events);
|
||||
@@ -129,8 +89,9 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
true
|
||||
);
|
||||
// applySettings fires the full lifecycle: onSuspending → onResumed.
|
||||
// ModuleReplicatorCouchDB starts continuous replication on onResumed
|
||||
// via fireAndForget.
|
||||
// The provider-independent scheduling feature owns any eligible
|
||||
// Continuous start; the daemon marker suppresses a duplicate
|
||||
// sync-on-start OneShot.
|
||||
await core.services.control.applySettings();
|
||||
// Lifecycle events (onSuspending) may re-enable suspension flags.
|
||||
// Clear them explicitly after the lifecycle completes. applyPartial
|
||||
@@ -153,7 +114,13 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
await core.services.replication.replicate(true);
|
||||
const result = await core.services.replication.replicateUnattended({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
if (!isReplicationCompleted(result)) {
|
||||
throw new Error(`Daemon polling replication did not complete (${result.status}).`);
|
||||
}
|
||||
if (consecutiveFailures > 0) {
|
||||
consecutiveFailures--;
|
||||
currentIntervalMs = Math.max(currentIntervalMs / 2, baseIntervalMs);
|
||||
@@ -182,11 +149,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
log("LiveSync mode: restoring sync settings and starting _changes feed");
|
||||
log("LiveSync mode: restoring sync settings and starting continuous synchronisation where supported");
|
||||
await restoreSyncSettings();
|
||||
// The applySettings() lifecycle fires onResumed → ModuleReplicatorCouchDB which
|
||||
// starts continuous replication via fireAndForget(openReplication). Don't call
|
||||
// openReplication directly — it races with the handler and causes dedup/termination.
|
||||
// The applySettings() lifecycle fires onResumed → the provider-
|
||||
// independent scheduling feature, which starts Continuous when
|
||||
// supported. Do not call a concrete Replicator directly.
|
||||
log("LiveSync active");
|
||||
const currentSettings = core.services.setting.currentSettings();
|
||||
if (!currentSettings.liveSync && !currentSettings.syncOnStart) {
|
||||
@@ -204,13 +171,19 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
|
||||
if (options.command === "sync") {
|
||||
writeStdoutLine(standardIo, "[Command] sync");
|
||||
const result = await core.services.replication.replicate(true);
|
||||
if (!result) {
|
||||
const result = await core.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
if (!isReplicationCompleted(result)) {
|
||||
// TODO: Standardise the logic for identifying the cause of replication
|
||||
// failure so that every reason (locked DB, version mismatch, network
|
||||
// error, etc.) is surfaced with a CLI-specific actionable message.
|
||||
const replicator = core.services.replicator.getActiveReplicator();
|
||||
if (replicator?.remoteLockedAndDeviceNotAccepted) {
|
||||
const recoveryHint = result.status === "failed" ? result.recoveryHint : undefined;
|
||||
if (
|
||||
recoveryHint?.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED ||
|
||||
recoveryHint?.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED
|
||||
) {
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`[Error] The remote database is locked and this device is not yet accepted.\n` +
|
||||
@@ -218,7 +191,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
);
|
||||
}
|
||||
}
|
||||
return !!result;
|
||||
return isReplicationCompleted(result);
|
||||
}
|
||||
|
||||
if (options.command === "p2p-peers") {
|
||||
@@ -227,7 +200,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
}
|
||||
const timeoutSec = parseTimeoutSeconds(options.commandArgs[0], "p2p-peers");
|
||||
writeStderrLine(standardIo, `[Command] p2p-peers timeout=${timeoutSec}s`);
|
||||
const peers = await collectPeers(core, timeoutSec);
|
||||
const peers = await collectPeers(core, context.p2pReplicator, timeoutSec);
|
||||
if (peers.length > 0) {
|
||||
standardIo.writeStdout(peers.map((peer) => `[peer]\t${peer.peerId}\t${peer.name}`).join("\n") + "\n");
|
||||
}
|
||||
@@ -244,14 +217,14 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
}
|
||||
const timeoutSec = parseTimeoutSeconds(options.commandArgs[1], "p2p-sync");
|
||||
writeStderrLine(standardIo, `[Command] p2p-sync peer=${peerToken} timeout=${timeoutSec}s`);
|
||||
const peer = await syncWithPeer(core, peerToken, timeoutSec);
|
||||
const peer = await syncWithPeer(core, context.p2pReplicator, peerToken, timeoutSec);
|
||||
writeStderrLine(standardIo, `[Done] P2P sync completed with ${peer.name} (${peer.peerId})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "p2p-host") {
|
||||
writeStderrLine(standardIo, "[Command] p2p-host");
|
||||
await openP2PHost(core);
|
||||
await openP2PHost(core, context.p2pReplicator);
|
||||
writeStderrLine(standardIo, "[Ready] P2P host is running. Press Ctrl+C to stop.");
|
||||
await new Promise(() => {});
|
||||
return true;
|
||||
@@ -757,88 +730,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "mark-resolved") {
|
||||
const id = options.commandArgs[0]?.trim();
|
||||
if (id) {
|
||||
let switched = false;
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
if (activated) {
|
||||
switched = true;
|
||||
return activated;
|
||||
}
|
||||
return currentSettings;
|
||||
}, false);
|
||||
|
||||
if (!switched) {
|
||||
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await core.services.control.applySettings();
|
||||
}
|
||||
|
||||
writeStderrLine(standardIo, `[Command] mark-resolved${id ? ` ${id}` : ""}`);
|
||||
await core.services.replication.markResolved();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
await verifyRemoteState(core, settings);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "unlock-remote") {
|
||||
const id = options.commandArgs[0]?.trim();
|
||||
if (id) {
|
||||
let switched = false;
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
if (activated) {
|
||||
switched = true;
|
||||
return activated;
|
||||
}
|
||||
return currentSettings;
|
||||
}, false);
|
||||
|
||||
if (!switched) {
|
||||
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await core.services.control.applySettings();
|
||||
}
|
||||
|
||||
writeStderrLine(standardIo, `[Command] unlock-remote${id ? ` ${id}` : ""}`);
|
||||
await core.services.replication.markUnlocked();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
await verifyRemoteState(core, settings);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "lock-remote") {
|
||||
const id = options.commandArgs[0]?.trim();
|
||||
if (id) {
|
||||
let switched = false;
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
if (activated) {
|
||||
switched = true;
|
||||
return activated;
|
||||
}
|
||||
return currentSettings;
|
||||
}, false);
|
||||
|
||||
if (!switched) {
|
||||
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await core.services.control.applySettings();
|
||||
}
|
||||
|
||||
writeStderrLine(standardIo, `[Command] lock-remote${id ? ` ${id}` : ""}`);
|
||||
await core.services.replication.markLocked();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
await verifyRemoteState(core, settings);
|
||||
return true;
|
||||
if (isCentralRemoteAdministrationCommand(options.command)) {
|
||||
return await runCentralRemoteAdministrationCommand(options, context, options.command);
|
||||
}
|
||||
|
||||
if (options.command === "remote-status") {
|
||||
@@ -863,13 +756,16 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
}
|
||||
|
||||
writeStderrLine(standardIo, `[Command] remote-status${id ? ` ${id}` : ""}`);
|
||||
const replicator = core.services.replicator.getActiveReplicator();
|
||||
if (!replicator) {
|
||||
standardIo.writeStderr("[Error] No active replicator found\n");
|
||||
const settings = core.services.setting.currentSettings();
|
||||
const resource = await core.services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.CONNECTION,
|
||||
settings
|
||||
);
|
||||
if (!resource) {
|
||||
standardIo.writeStderr("[Error] Remote status is unavailable for the current provider\n");
|
||||
return false;
|
||||
}
|
||||
const settings = core.services.setting.currentSettings();
|
||||
const status = await replicator.getRemoteStatus(settings);
|
||||
const status = await withOwnedRemoteResource(resource, (ownedResource) => ownedResource.getStatus());
|
||||
if (status === false) {
|
||||
standardIo.writeStderr("[Error] Failed to fetch remote status\n");
|
||||
return false;
|
||||
|
||||
@@ -2,10 +2,25 @@ import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import * as processSetting from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
import {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REPLICATION_COMPLETED,
|
||||
replicationFailed,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
function createStandardIoMock() {
|
||||
return {
|
||||
@@ -44,8 +59,26 @@ function createCoreMock() {
|
||||
markResolved: vi.fn(async () => {}),
|
||||
markUnlocked: vi.fn(async () => {}),
|
||||
markLocked: vi.fn(async () => {}),
|
||||
replicateUserInitiated: vi.fn(async () => REPLICATION_COMPLETED),
|
||||
},
|
||||
replicator: {
|
||||
runCentralRemoteAdministration: vi.fn(async ({ action }) => ({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
||||
observation: {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: action === CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
accepted: true,
|
||||
nodeId: "test-node-id",
|
||||
},
|
||||
})),
|
||||
createRemoteResource: vi.fn(async () => ({
|
||||
check: vi.fn(async () => ({ ok: true as const })),
|
||||
getStatus: vi.fn(async () => ({
|
||||
db_name: "test-db",
|
||||
doc_count: 42,
|
||||
})),
|
||||
dispose: vi.fn(async () => undefined),
|
||||
})),
|
||||
getActiveReplicator: vi.fn(() => ({
|
||||
nodeid: "test-node-id",
|
||||
initializeDatabaseForReplication: vi.fn(async () => {}),
|
||||
@@ -93,6 +126,7 @@ function makeOptions(command: CLIOptions["command"], commandArgs: string[]): CLI
|
||||
databasePath: "/tmp/vault",
|
||||
verbose: false,
|
||||
force: false,
|
||||
compatRemoteAdminExitZero: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,6 +265,27 @@ describe("runCommand abnormal cases", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reports a lock from the exact sync outcome without inspecting a replacement Replicator", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
replicationFailed(new Error("locked"), {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
runCommand(makeOptions("sync", []), {
|
||||
...context,
|
||||
core,
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("remote database is locked")
|
||||
);
|
||||
expect(core.services.replicator.getActiveReplicator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pull returns false for non-existing path", async () => {
|
||||
const core = createCoreMock();
|
||||
core.serviceModules.fileHandler.dbToStorage.mockResolvedValue(false);
|
||||
@@ -706,28 +761,158 @@ describe("runCommand abnormal cases", () => {
|
||||
});
|
||||
|
||||
describe("mark-resolved and unlock-remote commands", () => {
|
||||
it("reports a connection failure without claiming that every central remote is CouchDB", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED,
|
||||
detail: new Error("remote unavailable"),
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
const verificationOutput = core.services.context.standardIo.writeStderr.mock.calls
|
||||
.map(([chunk]: [string | Uint8Array]) =>
|
||||
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
|
||||
)
|
||||
.join("");
|
||||
expect(verificationOutput).toContain(
|
||||
"[Verification] Failed to connect to the configured remote: remote unavailable\n"
|
||||
);
|
||||
expect(verificationOutput).not.toContain("CouchDB");
|
||||
});
|
||||
|
||||
it("reports when the active remote configuration changes before administration begins", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.ACTIVE_CONFIGURATION_MISMATCH,
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
const verificationOutput = core.services.context.standardIo.writeStderr.mock.calls
|
||||
.map(([chunk]: [string | Uint8Array]) =>
|
||||
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
|
||||
)
|
||||
.join("");
|
||||
expect(verificationOutput).toContain(
|
||||
"[Verification] The active remote configuration changed before remote administration could begin.\n"
|
||||
);
|
||||
});
|
||||
|
||||
it("fails by default when remote administration cannot verify its postcondition", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the historical zero exit for returned verification failures only when requested", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
|
||||
});
|
||||
|
||||
const result = await runCommand(
|
||||
{ ...makeOptions("mark-resolved", []), compatRemoteAdminExitZero: true },
|
||||
{
|
||||
...context,
|
||||
core,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("does not hide a thrown remote mutation failure behind the compatibility option", async () => {
|
||||
const core = createCoreMock();
|
||||
const failure = new Error("mutation failed");
|
||||
core.services.replicator.runCentralRemoteAdministration.mockRejectedValueOnce(failure);
|
||||
|
||||
await expect(
|
||||
runCommand(
|
||||
{ ...makeOptions("mark-resolved", []), compatRemoteAdminExitZero: true },
|
||||
{
|
||||
...context,
|
||||
core,
|
||||
}
|
||||
)
|
||||
).rejects.toBe(failure);
|
||||
});
|
||||
|
||||
it("does not hide an unknown remote ID behind the compatibility option", async () => {
|
||||
const core = createCoreMock();
|
||||
|
||||
const result = await runCommand(
|
||||
{ ...makeOptions("mark-resolved", ["missing-remote"]), compatRemoteAdminExitZero: true },
|
||||
{
|
||||
...context,
|
||||
core,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails a lock command when the observed milestone remains unlocked", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
|
||||
observation: {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: false,
|
||||
accepted: true,
|
||||
nodeId: "test-node-id",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("lock-remote", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
const verificationOutput = core.services.context.standardIo.writeStderr.mock.calls
|
||||
.map(([chunk]: [string | Uint8Array]) =>
|
||||
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
|
||||
)
|
||||
.join("");
|
||||
expect(verificationOutput).toContain("[Verification] Remote Database: UNLOCKED\n");
|
||||
expect(verificationOutput).toContain("[Verification] Current Device Node ID (test-node-id): ACCEPTED\n");
|
||||
});
|
||||
|
||||
it("mark-resolved without args runs on active database", async () => {
|
||||
const core = createCoreMock();
|
||||
const remoteDatabase = {
|
||||
close: vi.fn(async () => undefined),
|
||||
get: vi.fn(async () => ({
|
||||
locked: false,
|
||||
accepted_nodes: ["test-node-id"],
|
||||
})),
|
||||
};
|
||||
core.services.replicator.getActiveReplicator.mockReturnValueOnce({
|
||||
nodeid: "test-node-id",
|
||||
initializeDatabaseForReplication: vi.fn(async () => undefined),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })),
|
||||
});
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markResolved).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||
});
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
expect(remoteDatabase.close).toHaveBeenCalledOnce();
|
||||
expect(core.services.replication.markResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mark-resolved with remote-id temporarily activates it and runs markResolved", async () => {
|
||||
@@ -745,7 +930,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markResolved).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||
});
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
expect(settings.activeConfigurationId).toBe("r1");
|
||||
expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false);
|
||||
@@ -758,7 +945,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markUnlocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||
});
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -777,7 +966,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markUnlocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||
});
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
expect(settings.activeConfigurationId).toBe("r1");
|
||||
expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false);
|
||||
@@ -790,7 +981,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markLocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
});
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -809,7 +1002,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markLocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
});
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
expect(settings.activeConfigurationId).toBe("r1");
|
||||
expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false);
|
||||
@@ -817,6 +1012,17 @@ describe("runCommand abnormal cases", () => {
|
||||
|
||||
it("remote-status without args outputs status of active remote configuration", async () => {
|
||||
const core = createCoreMock();
|
||||
const getStatus = vi.fn(async () => ({
|
||||
db_name: "test-db",
|
||||
doc_count: 42,
|
||||
}));
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const createRemoteResource = vi.fn(async () => ({
|
||||
check: vi.fn(),
|
||||
getStatus,
|
||||
dispose,
|
||||
}));
|
||||
core.services.replicator.createRemoteResource = createRemoteResource;
|
||||
const stdout = captureStdout(core);
|
||||
const result = await runCommand(makeOptions("remote-status", []), {
|
||||
...context,
|
||||
@@ -827,6 +1033,13 @@ describe("runCommand abnormal cases", () => {
|
||||
const parsedStatus = JSON.parse(fullOutput);
|
||||
expect(parsedStatus.db_name).toBe("test-db");
|
||||
expect(parsedStatus.doc_count).toBe(42);
|
||||
expect(createRemoteResource).toHaveBeenCalledWith(
|
||||
REMOTE_RESOURCE_KINDS.CONNECTION,
|
||||
core.services.setting.currentSettings()
|
||||
);
|
||||
expect(getStatus).toHaveBeenCalledOnce();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
expect(core.services.replicator.getActiveReplicator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("remote-status with remote-id temporarily activates it and outputs status", async () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { NodeServiceContext } from "@/apps/cli/services/NodeServiceContext";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { ReplicationSchedulingControl } from "@/serviceFeatures/replicationScheduling";
|
||||
|
||||
export type CLICommand =
|
||||
| "daemon"
|
||||
@@ -41,6 +42,8 @@ export interface CLIOptions {
|
||||
debug?: boolean;
|
||||
force?: boolean;
|
||||
writeSettings?: boolean;
|
||||
/** Restore the former zero exit code after a returned remote-administration verification failure. */
|
||||
compatRemoteAdminExitZero?: boolean;
|
||||
command: CLICommand;
|
||||
commandArgs: string[];
|
||||
interval?: number;
|
||||
@@ -50,6 +53,8 @@ export interface CLICommandContext {
|
||||
databasePath: string;
|
||||
vaultPath: string;
|
||||
core: LiveSyncBaseCore<NodeServiceContext, never>;
|
||||
/** Host-composition view used only to coordinate daemon-owned recurring work. */
|
||||
replicationScheduling: ReplicationSchedulingControl;
|
||||
/** Current-result contract owned by the P2P service feature. */
|
||||
p2pReplicator?: UseP2PReplicatorResult;
|
||||
settingsPath: string;
|
||||
|
||||
+24
-5
@@ -23,8 +23,8 @@ import type { CLICommand, CLICommandContext, CLIOptions } from "./commands/types
|
||||
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";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import { useP2PReplicatorFeature, type UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { ReplicationSchedulingControl } from "@/serviceFeatures/replicationScheduling";
|
||||
import { createNodeStandardIo, fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { writeStderrLine, writeStdoutLine } from "./cliOutput";
|
||||
@@ -103,6 +103,8 @@ Options:
|
||||
(defaults to database-path; allows separate PouchDB and vault dirs)
|
||||
--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
|
||||
Preserve the former zero exit code when remote-administration verification fails
|
||||
|
||||
Examples:
|
||||
livesync-cli ./my-database Run daemon (LiveSync mode)
|
||||
@@ -153,6 +155,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
let debug = false;
|
||||
let force = false;
|
||||
let writeSettings = false;
|
||||
let compatRemoteAdminExitZero = false;
|
||||
let interval: number | undefined;
|
||||
let command: CLICommand = "daemon";
|
||||
const commandArgs: string[] = [];
|
||||
@@ -212,6 +215,9 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
case "--write-settings":
|
||||
writeSettings = true;
|
||||
break;
|
||||
case "--compat-remote-admin-exit-zero":
|
||||
compatRemoteAdminExitZero = true;
|
||||
break;
|
||||
default: {
|
||||
if (!databasePath) {
|
||||
if (command === "daemon" && isCLICommand(token)) {
|
||||
@@ -253,6 +259,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
debug,
|
||||
force,
|
||||
writeSettings,
|
||||
compatRemoteAdminExitZero,
|
||||
command,
|
||||
commandArgs,
|
||||
interval,
|
||||
@@ -290,7 +297,10 @@ export async function main(
|
||||
) {
|
||||
const options = parseArgs(standardIo);
|
||||
if (options.interval && options.command !== "daemon") {
|
||||
writeStderrLine(standardIo, `Warning: --interval is only used in daemon mode, ignored for '${options.command}'`);
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`Warning: --interval is only used in daemon mode, ignored for '${options.command}'`
|
||||
);
|
||||
}
|
||||
const avoidStdoutNoise =
|
||||
options.command === "cat" ||
|
||||
@@ -420,7 +430,10 @@ export async function main(
|
||||
// In daemon mode the default handler must run so changes are applied to the filesystem.
|
||||
if (options.command !== "daemon") {
|
||||
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
|
||||
writeStderrLine(standardIo, `[Info] Replication result received, but not processed automatically in CLI mode.`);
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`[Info] Replication result received, but not processed automatically in CLI mode.`
|
||||
);
|
||||
return await Promise.resolve(true);
|
||||
}, -100);
|
||||
}
|
||||
@@ -472,6 +485,7 @@ export async function main(
|
||||
|
||||
// Create LiveSync core
|
||||
let p2pReplicator: UseP2PReplicatorResult | undefined;
|
||||
let replicationScheduling: ReplicationSchedulingControl | undefined;
|
||||
const core = new LiveSyncBaseCore(
|
||||
serviceHubInstance,
|
||||
(core: LiveSyncBaseCore<NodeServiceContext, never>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
||||
@@ -479,7 +493,8 @@ export async function main(
|
||||
},
|
||||
(core) => [],
|
||||
() => [], // No add-ons
|
||||
(core) => {
|
||||
(core, coreFeatureViews) => {
|
||||
replicationScheduling = coreFeatureViews.replicationScheduling;
|
||||
// Register P2P replicator feature.
|
||||
p2pReplicator = useP2PReplicatorFeature(core);
|
||||
// Add target filter to prevent internal files are handled
|
||||
@@ -511,6 +526,9 @@ export async function main(
|
||||
}
|
||||
}
|
||||
);
|
||||
if (!replicationScheduling) {
|
||||
throw new Error("Replication scheduling was not provided during core feature composition.");
|
||||
}
|
||||
|
||||
// Setup signal handlers for graceful shutdown
|
||||
const shutdown = async (signal: string) => {
|
||||
@@ -617,6 +635,7 @@ export async function main(
|
||||
databasePath,
|
||||
vaultPath,
|
||||
core,
|
||||
replicationScheduling,
|
||||
p2pReplicator,
|
||||
settingsPath,
|
||||
originalSyncSettings,
|
||||
|
||||
@@ -69,6 +69,7 @@ describe("CLI parseArgs", () => {
|
||||
const combined = standardIo.writeStdout.mock.calls.flat().join("");
|
||||
expect(combined).toContain("Usage:");
|
||||
expect(combined).toContain("livesync-cli <database-path> [options] <command> [command-args]");
|
||||
expect(combined).toContain("--compat-remote-admin-exit-zero");
|
||||
});
|
||||
|
||||
it("parses p2p-peers command and timeout", () => {
|
||||
@@ -215,4 +216,13 @@ describe("CLI parseArgs", () => {
|
||||
expect(parsed.writeSettings).toBe(true);
|
||||
expect(parsed.commandArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses the remote-administration exit compatibility option globally", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--compat-remote-admin-exit-zero", "mark-resolved"];
|
||||
const parsed = parseArgs();
|
||||
|
||||
expect(parsed.command).toBe("mark-resolved");
|
||||
expect(parsed.compatRemoteAdminExitZero).toBe(true);
|
||||
expect(parsed.commandArgs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.21-cli",
|
||||
"version": "1.0.22-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createNodeStandardIo } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { writeStderrLine } from "@/apps/cli/cliOutput";
|
||||
import { main, type CliCommandRunner } from "@/apps/cli/main";
|
||||
import { parseTimeoutSeconds } from "@/apps/cli/commands/p2p";
|
||||
import { runP2PReplicatorReplacementProbe } from "./p2p-replicator-replacement";
|
||||
import { runP2PReplicatorReplacementProbe } from "./p2p-replicator-replacement.test";
|
||||
|
||||
if (
|
||||
typeof (compatGlobal as unknown as Record<string, unknown>).RTCPeerConnection === "undefined" &&
|
||||
|
||||
+44
-35
@@ -1,6 +1,6 @@
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { CLICommandContext } from "@/apps/cli/commands/types";
|
||||
import { openP2PHost } from "@/apps/cli/commands/p2p";
|
||||
|
||||
@@ -15,32 +15,35 @@ function describeError(value: unknown): string {
|
||||
return value instanceof Error ? (value.stack ?? value.message) : String(value);
|
||||
}
|
||||
|
||||
async function waitForServing(replicator: LiveSyncTrysteroReplicator, timeoutMs: number): Promise<void> {
|
||||
type ProbeP2PService = Pick<P2PServiceViews, "transportLifecycle" | "peerDirectory" | "targetedTransfer">;
|
||||
|
||||
async function waitForServing(service: ProbeP2PService, timeoutMs: number): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started <= timeoutMs) {
|
||||
if (replicator.server?.isServing) return;
|
||||
if (service.transportLifecycle.isConnected) return;
|
||||
await delay(200);
|
||||
}
|
||||
throw new Error("The replacement P2P replicator did not start serving within the timeout");
|
||||
throw new Error("The stable P2P service did not start serving within the timeout");
|
||||
}
|
||||
|
||||
async function waitForPeer(
|
||||
replicator: LiveSyncTrysteroReplicator,
|
||||
service: ProbeP2PService,
|
||||
targetPeer: string,
|
||||
timeoutMs: number
|
||||
): Promise<{ peerId: string; name: string }> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started <= timeoutMs) {
|
||||
const peer = replicator.knownAdvertisements.find(
|
||||
(candidate) => candidate.name === targetPeer || candidate.peerId === targetPeer
|
||||
);
|
||||
const peer = service.peerDirectory
|
||||
.getPeers()
|
||||
.find((candidate) => candidate.name === targetPeer || candidate.peerId === targetPeer);
|
||||
if (peer) return peer;
|
||||
await delay(200);
|
||||
}
|
||||
const knownPeers = replicator.knownAdvertisements.map((peer) => `${peer.name} (${peer.peerId})`).join(", ");
|
||||
throw new Error(
|
||||
`Peer '${targetPeer}' was not discovered within the timeout. Known peers: ${knownPeers || "none"}`
|
||||
);
|
||||
const knownPeers = service.peerDirectory
|
||||
.getPeers()
|
||||
.map((peer) => `${peer.name} (${peer.peerId})`)
|
||||
.join(", ");
|
||||
throw new Error(`Peer '${targetPeer}' was not discovered within the timeout. Known peers: ${knownPeers || "none"}`);
|
||||
}
|
||||
|
||||
function assertPullSucceeded(result: unknown): void {
|
||||
@@ -50,15 +53,17 @@ function assertPullSucceeded(result: unknown): void {
|
||||
}
|
||||
|
||||
async function communicateWithPeer(
|
||||
replicator: LiveSyncTrysteroReplicator,
|
||||
service: ProbeP2PService,
|
||||
targetPeer: string,
|
||||
timeoutMs: number
|
||||
): Promise<{ peerId: string; name: string }> {
|
||||
await replicator.open();
|
||||
await waitForServing(replicator, timeoutMs);
|
||||
const peer = await waitForPeer(replicator, targetPeer, timeoutMs);
|
||||
assertPullSucceeded(await replicator.replicateFrom(peer.peerId, false));
|
||||
const pushResult = await replicator.requestSynchroniseToPeer(peer.peerId);
|
||||
if (!service.transportLifecycle.isConnected) {
|
||||
await service.transportLifecycle.connect();
|
||||
}
|
||||
await waitForServing(service, timeoutMs);
|
||||
const peer = await waitForPeer(service, targetPeer, timeoutMs);
|
||||
assertPullSucceeded(await service.targetedTransfer.pullFromPeer(peer.peerId, { showNotice: false }));
|
||||
const pushResult = await service.targetedTransfer.requestPushToPeer(peer.peerId);
|
||||
if (!pushResult || pushResult.ok !== true) {
|
||||
throw new Error(`P2P push failed: ${describeError(pushResult?.error)}`);
|
||||
}
|
||||
@@ -78,35 +83,39 @@ export async function runP2PReplicatorReplacementProbe(
|
||||
throw new Error("The CLI did not expose its P2P service-feature result to the integration probe");
|
||||
}
|
||||
|
||||
const firstReplicator = await openP2PHost(core);
|
||||
if (p2pReplicator.replicator !== firstReplicator) {
|
||||
throw new Error("The P2P service feature did not expose the newly created replicator");
|
||||
const initialActiveReplicator = core.services.replicator.getActiveReplicator();
|
||||
if (!initialActiveReplicator) {
|
||||
throw new Error("The CLI did not activate the initial P2P Replicator adapter");
|
||||
}
|
||||
const compatibilityFacade = p2pReplicator.replicator;
|
||||
const p2pService = await openP2PHost(core, p2pReplicator);
|
||||
|
||||
const firstPeer = await communicateWithPeer(firstReplicator, targetPeer, timeoutMs);
|
||||
const firstPeer = await communicateWithPeer(p2pService, targetPeer, timeoutMs);
|
||||
const initialised = await core.services.databaseEvents.initialiseDatabase(false, true, false);
|
||||
if (!initialised) {
|
||||
throw new Error("Database reinitialisation failed during the P2P replacement probe");
|
||||
}
|
||||
|
||||
const replacementReplicator = p2pReplicator.replicator;
|
||||
if (core.services.replicator.getActiveReplicator() !== replacementReplicator) {
|
||||
throw new Error("ReplicatorService did not activate the P2P service feature's replacement replicator");
|
||||
const replacementActiveReplicator = core.services.replicator.getActiveReplicator();
|
||||
if (!replacementActiveReplicator) {
|
||||
throw new Error("ReplicatorService did not activate a replacement P2P Replicator adapter");
|
||||
}
|
||||
if (replacementReplicator === firstReplicator) {
|
||||
throw new Error("Database reinitialisation retained the previous P2P replicator instance");
|
||||
if (replacementActiveReplicator === initialActiveReplicator) {
|
||||
throw new Error("Database reinitialisation retained the previous active P2P Replicator adapter");
|
||||
}
|
||||
if (firstReplicator.server !== undefined) {
|
||||
throw new Error("The previous P2P replicator remained open after replacement");
|
||||
if (p2pReplicator.replicator !== compatibilityFacade) {
|
||||
throw new Error("Database reinitialisation replaced the stable P2P service compatibility facade");
|
||||
}
|
||||
if (p2pService.transportLifecycle.isConnected) {
|
||||
throw new Error("Database reinitialisation left the database-bound P2P room open");
|
||||
}
|
||||
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.P2P_AutoStart = true;
|
||||
await core.services.control.applySettings();
|
||||
const resumedReplicator = p2pReplicator.replicator;
|
||||
await waitForServing(resumedReplicator, timeoutMs);
|
||||
if (firstReplicator.server !== undefined) {
|
||||
throw new Error("A setting event reopened the previous P2P replicator");
|
||||
await waitForServing(p2pService, timeoutMs);
|
||||
if (p2pReplicator.replicator !== compatibilityFacade) {
|
||||
throw new Error("A setting event replaced the stable P2P service compatibility facade");
|
||||
}
|
||||
|
||||
const encoded = new TextEncoder().encode(noteContent);
|
||||
@@ -118,7 +127,7 @@ export async function runP2PReplicatorReplacementProbe(
|
||||
});
|
||||
await core.serviceModules.fileHandler.storeFileToDB(notePath as FilePathWithPrefix, true);
|
||||
|
||||
const replacementPeer = await communicateWithPeer(resumedReplicator, targetPeer, timeoutMs);
|
||||
const replacementPeer = await communicateWithPeer(p2pService, targetPeer, timeoutMs);
|
||||
if (replacementPeer.name !== firstPeer.name) {
|
||||
throw new Error(
|
||||
`The replacement replicator reached '${replacementPeer.name}' instead of the original peer '${firstPeer.name}'`
|
||||
@@ -126,7 +135,7 @@ export async function runP2PReplicatorReplacementProbe(
|
||||
}
|
||||
|
||||
core.services.context.standardIo.writeStdout(
|
||||
`[Probe] P2P replicator replaced, old transport stayed closed, and ${notePath} was sent through the replacement.\n`
|
||||
`[Probe] The active P2P adapter was replaced, the stable service reopened, and ${notePath} was sent through it.\n`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"test:decoupled-vault": "deno test --env-file=.test.env -A --no-check test-decoupled-vault.ts",
|
||||
"test:remote-commands": "deno test --env-file=.test.env -A --no-check test-remote-commands.ts",
|
||||
"test:settings-writeback": "deno test -A --no-check test-settings-writeback.ts",
|
||||
"test:remote-administration-exit-codes": "deno test -A --no-check test-remote-administration-exit-codes.ts",
|
||||
"test:push-pull": "deno test --env-file=.test.env -A --no-check test-push-pull.ts",
|
||||
"test:setup-put-cat": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts",
|
||||
"test:mirror": "deno test --env-file=.test.env -A --no-check test-mirror.ts",
|
||||
|
||||
@@ -143,8 +143,12 @@ export async function createCompressionBenchmarkDataset(options: {
|
||||
);
|
||||
await copyRepositoryFile("json", "package.json", "package.json");
|
||||
await copyRepositoryFile("json", "manifest.json", "manifest.json");
|
||||
await copyRepositoryFile("ts", "src/modules/core/ModuleReplicator.ts", "ModuleReplicator.ts");
|
||||
await copyRepositoryFile("ts", "src/modules/core/ReplicateResultProcessor.ts", "ReplicateResultProcessor.ts");
|
||||
await copyRepositoryFile("ts", "src/serviceFeatures/replication/index.ts", "replicationFeature.ts");
|
||||
await copyRepositoryFile(
|
||||
"ts",
|
||||
"src/serviceFeatures/replication/ReplicateResultProcessor.ts",
|
||||
"ReplicateResultProcessor.ts"
|
||||
);
|
||||
|
||||
const markdownBytes = await Deno.readFile(join(repositoryRoot, "docs/settings.md"));
|
||||
const gzipPath = join(datasetRoot, "gz", "settings.md.gz");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const TASKS = [
|
||||
"test:settings-writeback",
|
||||
"test:remote-administration-exit-codes",
|
||||
"test:setup-put-cat",
|
||||
"test:mirror",
|
||||
"test:daemon",
|
||||
|
||||
@@ -79,7 +79,6 @@ Deno.test("benchmark cases record scope and limitations for paper use", () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("CouchDB latency proxy applies half the requested RTT in each direction", async () => {
|
||||
const backendPort = getFreePort();
|
||||
const proxyPort = getFreePort();
|
||||
@@ -156,8 +155,8 @@ Deno.test("compression benchmark dataset covers representative file kinds determ
|
||||
"images/quick-setup/guide-quick-setup-first-setup-uri.png",
|
||||
"package.json",
|
||||
"manifest.json",
|
||||
"src/modules/core/ModuleReplicator.ts",
|
||||
"src/modules/core/ReplicateResultProcessor.ts",
|
||||
"src/serviceFeatures/replication/index.ts",
|
||||
"src/serviceFeatures/replication/ReplicateResultProcessor.ts",
|
||||
];
|
||||
try {
|
||||
for (const [index, relativePath] of repositoryFiles.entries()) {
|
||||
|
||||
@@ -39,7 +39,7 @@ async function runReplacementProbe(
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("p2p lifecycle: replacement keeps real CLI communication on the current replicator", async () => {
|
||||
Deno.test("p2p lifecycle: active-adapter replacement keeps real CLI communication on the stable service", async () => {
|
||||
const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/";
|
||||
const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "20");
|
||||
const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "60");
|
||||
@@ -82,11 +82,8 @@ Deno.test("p2p lifecycle: replacement keeps real CLI communication on the curren
|
||||
try {
|
||||
await host.waitUntilContains("P2P host is running", 20000);
|
||||
const probe = await runReplacementProbe(probeVault, probeSettings, hostPeerName, probeTimeoutMs);
|
||||
assert(
|
||||
probe.code === 0,
|
||||
`P2P replacement probe failed\nstdout: ${probe.stdout}\nstderr: ${probe.stderr}`
|
||||
);
|
||||
assertStringIncludes(probe.stdout, "[Probe] P2P replicator replaced");
|
||||
assert(probe.code === 0, `P2P replacement probe failed\nstdout: ${probe.stdout}\nstderr: ${probe.stderr}`);
|
||||
assertStringIncludes(probe.stdout, "[Probe] The active P2P adapter was replaced");
|
||||
|
||||
const syncResult = await runCli(
|
||||
verifierVault,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { assertEquals, assertStringIncludes } from "@std/assert";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCli } from "./helpers/cli.ts";
|
||||
import { applyCouchdbSettings, applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
||||
|
||||
async function prepareFixture(prefix: string) {
|
||||
const workDir = await TempDir.create(prefix);
|
||||
const settingsFile = workDir.join("settings.json");
|
||||
const databaseDir = workDir.join("database");
|
||||
await Deno.mkdir(databaseDir, { recursive: true });
|
||||
await initSettingsFile(settingsFile);
|
||||
return { workDir, settingsFile, databaseDir };
|
||||
}
|
||||
|
||||
Deno.test("remote administration process exit policy distinguishes returned verification failure", async () => {
|
||||
const fixture = await prepareFixture("livesync-cli-remote-admin-exit");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
await applyP2pSettings(
|
||||
settingsFile,
|
||||
"remote-admin-exit-room",
|
||||
"remote-admin-exit-passphrase",
|
||||
"remote-admin-exit-tests",
|
||||
"ws://127.0.0.1:1/",
|
||||
"~.*",
|
||||
"none"
|
||||
);
|
||||
await applyP2pTestTweaks(settingsFile, "remote-admin-exit-device", "remote-admin-exit-passphrase");
|
||||
|
||||
const defaultFailure = await runCli(databaseDir, "--settings", settingsFile, "mark-resolved");
|
||||
assertEquals(defaultFailure.code, 1, defaultFailure.combined);
|
||||
assertStringIncludes(
|
||||
defaultFailure.combined,
|
||||
"[Verification] Remote administration is unavailable for this provider."
|
||||
);
|
||||
assertStringIncludes(defaultFailure.combined, "[Error] Command 'mark-resolved' failed");
|
||||
|
||||
const compatibilitySuccess = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"--compat-remote-admin-exit-zero",
|
||||
"mark-resolved"
|
||||
);
|
||||
assertEquals(compatibilitySuccess.code, 0, compatibilitySuccess.combined);
|
||||
assertStringIncludes(
|
||||
compatibilitySuccess.combined,
|
||||
"[Verification] Remote administration is unavailable for this provider."
|
||||
);
|
||||
assertStringIncludes(compatibilitySuccess.combined, "[Done] Command 'mark-resolved' completed");
|
||||
});
|
||||
|
||||
Deno.test("remote administration compatibility does not hide a thrown mutation failure", async () => {
|
||||
const fixture = await prepareFixture("livesync-cli-remote-admin-mutation");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
await applyCouchdbSettings(
|
||||
settingsFile,
|
||||
"http://127.0.0.1:1/",
|
||||
"unreachable-user",
|
||||
"unreachable-password",
|
||||
"unreachable-database"
|
||||
);
|
||||
|
||||
const mutationFailure = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"--compat-remote-admin-exit-zero",
|
||||
"mark-resolved"
|
||||
);
|
||||
assertEquals(mutationFailure.code, 1, mutationFailure.combined);
|
||||
assertStringIncludes(mutationFailure.combined, "[Command] mark-resolved");
|
||||
assertStringIncludes(mutationFailure.combined, "[Error] Failed to start:");
|
||||
});
|
||||
@@ -146,6 +146,13 @@ export class WebAppRuntime {
|
||||
return this.paneHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import local files and complete the readiness boundary needed by optional P2P.
|
||||
*
|
||||
* An unconfigured central remote cannot use the normal offline-scan path, so
|
||||
* the explicit WebApp scan completes the same post-scan finalisation without
|
||||
* treating the central remote as configured.
|
||||
*/
|
||||
async scanLocalFiles(): Promise<boolean> {
|
||||
const core = this.core;
|
||||
const fileAccess = this.platformServiceModules?.vaultAccess;
|
||||
@@ -171,7 +178,17 @@ export class WebAppRuntime {
|
||||
this.addLog(`Failed to import ${path}: ${String(error)}`, LOG_LEVEL_NOTICE, "scan");
|
||||
}
|
||||
}
|
||||
return succeeded;
|
||||
if (!succeeded || core.services.appLifecycle.isReady()) {
|
||||
return succeeded;
|
||||
}
|
||||
if (!(await core.services.databaseEvents.onDatabaseInitialised(false))) {
|
||||
return false;
|
||||
}
|
||||
if (!(await core.services.fileProcessing.commitPendingFileEvents())) {
|
||||
return false;
|
||||
}
|
||||
core.services.appLifecycle.markIsReady();
|
||||
return true;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.21-webapp",
|
||||
"version": "1.0.22-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.21-webpeer",
|
||||
"version": "1.0.22-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -53,7 +53,7 @@ export class P2PCheckSession {
|
||||
|
||||
try {
|
||||
await runtime.start();
|
||||
await runtime.currentReplicator.makeSureOpened();
|
||||
await runtime.p2p.transportLifecycle.connect();
|
||||
} catch (error) {
|
||||
await this.stop();
|
||||
throw error;
|
||||
|
||||
@@ -3,10 +3,9 @@ import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFu
|
||||
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
|
||||
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
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 { unique } from "octagonal-wheels/collection";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
|
||||
@@ -48,7 +47,7 @@ function removeFromList(item: string, list: string): string {
|
||||
export class WebPeerRuntime {
|
||||
readonly context: ServiceContext;
|
||||
readonly services: LiveSyncBrowserServiceHub<ServiceContext>;
|
||||
readonly p2p: UseP2PReplicatorResult;
|
||||
readonly p2p: P2PServiceViews;
|
||||
readonly p2pLogCollector: P2PLogCollector;
|
||||
readonly paneHost: P2PReplicatorPaneHost;
|
||||
|
||||
@@ -87,10 +86,6 @@ export class WebPeerRuntime {
|
||||
return this.context.events;
|
||||
}
|
||||
|
||||
get currentReplicator(): LiveSyncTrysteroReplicator {
|
||||
return this.p2p.replicator;
|
||||
}
|
||||
|
||||
get settings(): P2PSyncSetting {
|
||||
return this.services.setting.currentSettings();
|
||||
}
|
||||
@@ -119,9 +114,7 @@ export class WebPeerRuntime {
|
||||
}
|
||||
this.services.appLifecycle.markIsReady();
|
||||
this.events.emitEvent(EVENT_LAYOUT_READY);
|
||||
if (this.settings.P2P_AutoStart && this.settings.P2P_Enabled) {
|
||||
compatGlobal.setTimeout(() => void this.currentReplicator.open(), 100);
|
||||
}
|
||||
await this.services.appLifecycle.onResumed();
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -151,12 +144,12 @@ export class WebPeerRuntime {
|
||||
this.menu = new Menu()
|
||||
.addItem((item) =>
|
||||
item.setTitle("📥 Only fetch").onClick(async () => {
|
||||
await this.currentReplicator.replicateFrom(peer.peerId);
|
||||
await this.p2p.targetedTransfer.pullFromPeer(peer.peerId);
|
||||
})
|
||||
)
|
||||
.addItem((item) =>
|
||||
item.setTitle("📤 Only send").onClick(async () => {
|
||||
await this.currentReplicator.requestSynchroniseToPeer(peer.peerId);
|
||||
await this.p2p.targetedTransfer.requestPushToPeer(peer.peerId);
|
||||
})
|
||||
)
|
||||
.addSeparator()
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
MILESTONE_DOCID,
|
||||
type EntryMilestoneInfo,
|
||||
type RemoteDBSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||
applyCentralRemoteAdministrationMutation,
|
||||
milestoneSatisfiesCentralRemoteAdministration,
|
||||
centralRemoteAdministrationVerificationFailed,
|
||||
centralRemoteAdministrationVerified,
|
||||
supportedCapability,
|
||||
type MilestoneCentralRemoteAdministrationObservation,
|
||||
type CentralRemoteAdministrationFailureReason,
|
||||
type CentralRemoteAdministrationRequest,
|
||||
type CentralRemoteAdministrationReplicator,
|
||||
type CentralRemoteAdministrationResult,
|
||||
type CentralRemoteAdministrationRunner,
|
||||
type ReplicatorInstance,
|
||||
type SupportedCapability,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
/**
|
||||
* Central milestone administration shared by the two central providers.
|
||||
*
|
||||
* The provider definition selects a reader before mutation. CouchDB then owns
|
||||
* a fresh verification connection, while Object Storage borrows the active
|
||||
* Journal client. Local node identity is established before either mutation.
|
||||
*/
|
||||
const JOURNAL_MILESTONE_PATH = "_00000000-milestone.json";
|
||||
|
||||
/** A provider read result, including failures which settled without a throw. */
|
||||
type CentralMilestoneReadResult =
|
||||
| { readonly milestone: EntryMilestoneInfo | false | undefined }
|
||||
| { readonly failureReason: CentralRemoteAdministrationFailureReason; readonly detail?: unknown };
|
||||
|
||||
/** A settings-bound postcondition reader prepared before remote mutation. */
|
||||
type PreparedCentralMilestoneReader = () => Promise<CentralMilestoneReadResult>;
|
||||
|
||||
/** Select and validate the provider-specific reader without performing I/O. */
|
||||
type CentralMilestoneReaderPreparer = (
|
||||
replicator: CentralRemoteAdministrationReplicator,
|
||||
setting: RemoteDBSettings
|
||||
) => PreparedCentralMilestoneReader;
|
||||
|
||||
type CouchDBAdministrationReplicator = CentralRemoteAdministrationReplicator &
|
||||
Pick<LiveSyncCouchDBReplicator, "connectRemoteCouchDBWithSetting" | "isMobile">;
|
||||
|
||||
type JournalAdministrationClient = Pick<LiveSyncJournalReplicator["client"], "downloadJsonWithResult">;
|
||||
|
||||
function isCentralRemoteAdministrationReplicator(
|
||||
replicator: ReplicatorInstance
|
||||
): replicator is CentralRemoteAdministrationReplicator {
|
||||
return (
|
||||
"nodeid" in replicator &&
|
||||
typeof replicator.nodeid === "string" &&
|
||||
"markRemoteResolved" in replicator &&
|
||||
typeof replicator.markRemoteResolved === "function" &&
|
||||
"markRemoteLocked" in replicator &&
|
||||
typeof replicator.markRemoteLocked === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureLocalNodeIdentity(
|
||||
replicator: CentralRemoteAdministrationReplicator
|
||||
): Promise<CentralRemoteAdministrationResult | undefined> {
|
||||
if (replicator.nodeid) {
|
||||
return undefined;
|
||||
}
|
||||
if ((await replicator.initializeDatabaseForReplication()) && replicator.nodeid) {
|
||||
return undefined;
|
||||
}
|
||||
return centralRemoteAdministrationVerificationFailed(
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
function observeMilestone(
|
||||
replicator: CentralRemoteAdministrationReplicator,
|
||||
milestone: EntryMilestoneInfo
|
||||
): MilestoneCentralRemoteAdministrationObservation {
|
||||
return {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: !!milestone.locked,
|
||||
accepted: !!milestone.accepted_nodes?.includes(replicator.nodeid),
|
||||
nodeId: replicator.nodeid,
|
||||
};
|
||||
}
|
||||
|
||||
function resultFromMilestone(
|
||||
replicator: CentralRemoteAdministrationReplicator,
|
||||
request: CentralRemoteAdministrationRequest,
|
||||
milestone: EntryMilestoneInfo | false | undefined
|
||||
): CentralRemoteAdministrationResult {
|
||||
if (!milestone) {
|
||||
return centralRemoteAdministrationVerificationFailed(
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND
|
||||
);
|
||||
}
|
||||
const observation = observeMilestone(replicator, milestone);
|
||||
return milestoneSatisfiesCentralRemoteAdministration(request.action, observation)
|
||||
? centralRemoteAdministrationVerified(observation)
|
||||
: centralRemoteAdministrationVerificationFailed(
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
|
||||
{
|
||||
observation,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply and verify the central milestone protocol without selecting a provider.
|
||||
*
|
||||
* The provider definition has already selected the reader preparer. Preparing
|
||||
* it before mutation rejects incomplete composition before a remote write and
|
||||
* binds any provider-owned client which must be used for postcondition reading.
|
||||
*/
|
||||
async function runCentralRemoteAdministration(
|
||||
replicator: CentralRemoteAdministrationReplicator,
|
||||
setting: RemoteDBSettings,
|
||||
request: CentralRemoteAdministrationRequest,
|
||||
prepareMilestoneReader: CentralMilestoneReaderPreparer
|
||||
): Promise<CentralRemoteAdministrationResult> {
|
||||
const identityFailure = await ensureLocalNodeIdentity(replicator);
|
||||
if (identityFailure) return identityFailure;
|
||||
|
||||
const readMilestone = prepareMilestoneReader(replicator, setting);
|
||||
await applyCentralRemoteAdministrationMutation(replicator, setting, request.action);
|
||||
|
||||
const readResult = await readMilestone();
|
||||
if ("failureReason" in readResult) {
|
||||
return centralRemoteAdministrationVerificationFailed(readResult.failureReason, { detail: readResult.detail });
|
||||
}
|
||||
return resultFromMilestone(replicator, request, readResult.milestone);
|
||||
}
|
||||
|
||||
function requireCouchDBAdministrationOperations(
|
||||
replicator: CentralRemoteAdministrationReplicator
|
||||
): asserts replicator is CouchDBAdministrationReplicator {
|
||||
if (
|
||||
!("connectRemoteCouchDBWithSetting" in replicator) ||
|
||||
typeof replicator.connectRemoteCouchDBWithSetting !== "function" ||
|
||||
!("isMobile" in replicator) ||
|
||||
typeof replicator.isMobile !== "function"
|
||||
) {
|
||||
throw new Error("The configured CouchDB administration adapter does not provide milestone access.");
|
||||
}
|
||||
}
|
||||
|
||||
function prepareCouchDBMilestoneReader(
|
||||
replicator: CentralRemoteAdministrationReplicator,
|
||||
setting: RemoteDBSettings
|
||||
): PreparedCentralMilestoneReader {
|
||||
requireCouchDBAdministrationOperations(replicator);
|
||||
|
||||
return async () => {
|
||||
// This verification connection is fresh and owned by this read. It is
|
||||
// always closed here rather than retained by the active Replicator.
|
||||
let connection: Awaited<ReturnType<CouchDBAdministrationReplicator["connectRemoteCouchDBWithSetting"]>>;
|
||||
try {
|
||||
connection = await replicator.connectRemoteCouchDBWithSetting(setting, replicator.isMobile(), true);
|
||||
} catch (error) {
|
||||
return { failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED, detail: error };
|
||||
}
|
||||
if (typeof connection === "string") {
|
||||
return {
|
||||
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED,
|
||||
detail: connection,
|
||||
};
|
||||
}
|
||||
|
||||
let milestone: EntryMilestoneInfo | undefined;
|
||||
let observationError: unknown;
|
||||
try {
|
||||
milestone = await connection.db.get<EntryMilestoneInfo>(MILESTONE_DOCID);
|
||||
} catch (error) {
|
||||
observationError = error;
|
||||
}
|
||||
try {
|
||||
await connection.close();
|
||||
} catch (error) {
|
||||
observationError ??= error;
|
||||
}
|
||||
if (observationError !== undefined) {
|
||||
return {
|
||||
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
|
||||
detail: observationError,
|
||||
};
|
||||
}
|
||||
return { milestone };
|
||||
};
|
||||
}
|
||||
|
||||
function isJournalAdministrationClient(client: unknown): client is JournalAdministrationClient {
|
||||
return (
|
||||
typeof client === "object" &&
|
||||
client !== null &&
|
||||
"downloadJsonWithResult" in client &&
|
||||
typeof client.downloadJsonWithResult === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function requireJournalAdministrationClient(
|
||||
replicator: CentralRemoteAdministrationReplicator
|
||||
): JournalAdministrationClient {
|
||||
if (!("client" in replicator) || !isJournalAdministrationClient(replicator.client)) {
|
||||
throw new Error("The configured Object Storage administration adapter does not provide milestone access.");
|
||||
}
|
||||
return replicator.client;
|
||||
}
|
||||
|
||||
function assertNeverJournalStorageRead(result: never): never {
|
||||
throw new Error(`Unexpected Journal storage read result: ${String(result)}`);
|
||||
}
|
||||
|
||||
function prepareObjectStorageMilestoneReader(
|
||||
replicator: CentralRemoteAdministrationReplicator
|
||||
): PreparedCentralMilestoneReader {
|
||||
// The Journal client belongs to the active Replicator. This reader borrows
|
||||
// it for the provider's distinct milestone path and must not dispose it.
|
||||
const client = requireJournalAdministrationClient(replicator);
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
const result = await client.downloadJsonWithResult<EntryMilestoneInfo>(JOURNAL_MILESTONE_PATH);
|
||||
switch (result.status) {
|
||||
case "available":
|
||||
return { milestone: result.value };
|
||||
case "not-found":
|
||||
return { milestone: undefined };
|
||||
case "unavailable":
|
||||
return {
|
||||
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
|
||||
detail: result.error,
|
||||
};
|
||||
default:
|
||||
return assertNeverJournalStorageRead(result);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
|
||||
detail: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const runCouchDBCentralRemoteAdministration: CentralRemoteAdministrationRunner = async (
|
||||
replicator,
|
||||
setting,
|
||||
request
|
||||
) => {
|
||||
if (!isCentralRemoteAdministrationReplicator(replicator)) {
|
||||
return centralRemoteAdministrationVerificationFailed(
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE
|
||||
);
|
||||
}
|
||||
return await runCentralRemoteAdministration(replicator, setting, request, prepareCouchDBMilestoneReader);
|
||||
};
|
||||
|
||||
const runObjectStorageCentralRemoteAdministration: CentralRemoteAdministrationRunner = async (
|
||||
replicator,
|
||||
setting,
|
||||
request
|
||||
) => {
|
||||
if (!isCentralRemoteAdministrationReplicator(replicator)) {
|
||||
return centralRemoteAdministrationVerificationFailed(
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE
|
||||
);
|
||||
}
|
||||
return await runCentralRemoteAdministration(replicator, setting, request, prepareObjectStorageMilestoneReader);
|
||||
};
|
||||
|
||||
/** CouchDB mutation and milestone postcondition verification capability. */
|
||||
export const COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY: SupportedCapability<CentralRemoteAdministrationRunner> =
|
||||
supportedCapability(runCouchDBCentralRemoteAdministration);
|
||||
|
||||
/** Object Storage mutation and milestone postcondition verification capability. */
|
||||
export const OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY: SupportedCapability<CentralRemoteAdministrationRunner> =
|
||||
supportedCapability(runObjectStorageCentralRemoteAdministration);
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
} from "./centralRemoteAdministration";
|
||||
|
||||
describe("central remote administration capabilities", () => {
|
||||
it("mutates CouchDB, verifies the requested postcondition, and closes only the owned connection", async () => {
|
||||
const rawDatabaseClose = vi.fn(async () => undefined);
|
||||
const close = vi.fn(async () => undefined);
|
||||
const database = {
|
||||
get: vi.fn(async () => ({ locked: true, accepted_nodes: ["node-1"] })),
|
||||
close: rawDatabaseClose,
|
||||
};
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
isMobile: vi.fn(() => false),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: database, close })),
|
||||
};
|
||||
const setting = { ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB };
|
||||
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||
|
||||
await expect(
|
||||
capability.run(replicator as never, setting, { action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK })
|
||||
).resolves.toEqual({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
||||
observation: {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: true,
|
||||
accepted: true,
|
||||
nodeId: "node-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(replicator.markRemoteLocked).toHaveBeenCalledWith(setting, true, false);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(rawDatabaseClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a typed CouchDB failure when the observed milestone does not satisfy the action", async () => {
|
||||
const close = vi.fn(async () => undefined);
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
isMobile: vi.fn(() => false),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({
|
||||
db: { get: vi.fn(async () => ({ locked: false, accepted_nodes: ["node-1"] })) },
|
||||
close,
|
||||
})),
|
||||
};
|
||||
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||
|
||||
const result = await capability.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||
{
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
|
||||
observation: { kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, locked: false },
|
||||
});
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not mutate when initialisation succeeds without publishing a local node identity", async () => {
|
||||
const replicator = {
|
||||
nodeid: "",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
isMobile: vi.fn(() => false),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => "must not connect"),
|
||||
};
|
||||
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||
|
||||
await expect(
|
||||
capability.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE,
|
||||
});
|
||||
expect(replicator.markRemoteResolved).not.toHaveBeenCalled();
|
||||
expect(replicator.connectRemoteCouchDBWithSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a CouchDB mutation exception to reject before verification", async () => {
|
||||
const failure = new Error("write failed");
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
isMobile: vi.fn(() => false),
|
||||
markRemoteLocked: vi.fn(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(),
|
||||
};
|
||||
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||
|
||||
await expect(
|
||||
capability.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||
{
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||
}
|
||||
)
|
||||
).rejects.toBe(failure);
|
||||
expect(replicator.connectRemoteCouchDBWithSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mutates Object Storage and verifies its milestone postcondition", async () => {
|
||||
const milestone = { locked: false, accepted_nodes: ["node-1"] };
|
||||
const downloadJson = vi.fn(async () => milestone);
|
||||
const downloadJsonWithResult = vi.fn(async () => ({
|
||||
status: "available" as const,
|
||||
value: milestone,
|
||||
}));
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
client: { downloadJson, downloadJsonWithResult },
|
||||
};
|
||||
const setting = { ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO };
|
||||
const capability = OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||
|
||||
await expect(
|
||||
capability.run(replicator as never, setting, {
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
||||
observation: {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: false,
|
||||
accepted: true,
|
||||
nodeId: "node-1",
|
||||
},
|
||||
});
|
||||
expect(replicator.markRemoteResolved).toHaveBeenCalledWith(setting);
|
||||
expect(downloadJsonWithResult).toHaveBeenCalledWith("_00000000-milestone.json");
|
||||
expect(downloadJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a missing Object Storage milestone as an unverified postcondition", async () => {
|
||||
const downloadJson = vi.fn(async () => false);
|
||||
const downloadJsonWithResult = vi.fn(async () => ({ status: "not-found" as const }));
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
client: { downloadJson, downloadJsonWithResult },
|
||||
};
|
||||
|
||||
const result = await OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO },
|
||||
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND,
|
||||
});
|
||||
expect(downloadJsonWithResult).toHaveBeenCalledWith("_00000000-milestone.json");
|
||||
expect(downloadJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a typed failure with diagnostic detail when Object Storage milestone reading is unavailable", async () => {
|
||||
const diagnostic = new Error("object storage unavailable");
|
||||
const downloadJson = vi.fn(async () => false);
|
||||
const downloadJsonWithResult = vi.fn(async () => ({
|
||||
status: "unavailable" as const,
|
||||
error: diagnostic,
|
||||
}));
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
client: { downloadJson, downloadJsonWithResult },
|
||||
};
|
||||
|
||||
const result = await OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO },
|
||||
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
|
||||
detail: diagnostic,
|
||||
});
|
||||
expect(downloadJsonWithResult).toHaveBeenCalledWith("_00000000-milestone.json");
|
||||
expect(downloadJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an incomplete CouchDB milestone adapter before mutation", async () => {
|
||||
const markRemoteLocked = vi.fn(async () => undefined);
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
markRemoteLocked,
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
await expect(
|
||||
COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK }
|
||||
)
|
||||
).rejects.toThrow("The configured CouchDB administration adapter does not provide milestone access.");
|
||||
expect(markRemoteLocked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an Object Storage adapter which only exposes lossy milestone reading", async () => {
|
||||
const markRemoteResolved = vi.fn(async () => undefined);
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved,
|
||||
client: { downloadJson: vi.fn(async () => false) },
|
||||
};
|
||||
|
||||
await expect(
|
||||
OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO },
|
||||
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
|
||||
)
|
||||
).rejects.toThrow("The configured Object Storage administration adapter does not provide milestone access.");
|
||||
expect(markRemoteResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -177,6 +177,7 @@ describe("packaged Commonlib compatibility gate", () => {
|
||||
databaseService: {},
|
||||
fileProcessingService: { commitPendingFileEvents: vi.fn().mockResolvedValue(true) },
|
||||
replicatorService: {
|
||||
acquireActiveReplicatorContext: vi.fn().mockResolvedValue(undefined),
|
||||
getActiveReplicator: () => ({ openReplication }),
|
||||
runFiniteReplicationActivity,
|
||||
},
|
||||
|
||||
@@ -9791,6 +9791,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "仅供测试 - 通过同步文件的较新副本来解决文件冲突,这可能会覆盖修改过的文件。请注意 ",
|
||||
"zh-tw": "僅供測試 —— 透過同步較新的檔案版本解決衝突,這可能會覆寫已修改的檔案,請注意。",
|
||||
},
|
||||
"The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing.":
|
||||
{
|
||||
def: "The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing.",
|
||||
},
|
||||
"The connection to the server has been configured successfully. As the next step,": {
|
||||
def: "The connection to the server has been configured successfully. As the next step,",
|
||||
es: "La conexión con el servidor se ha configurado correctamente. Como paso siguiente,",
|
||||
|
||||
@@ -1062,6 +1062,7 @@
|
||||
"Target patterns": "Target patterns",
|
||||
"Test Settings and Continue": "Test Settings and Continue",
|
||||
"Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": "Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.",
|
||||
"The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing.": "The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing.",
|
||||
"The connection to the server has been configured successfully. As the next step,": "The connection to the server has been configured successfully. As the next step,",
|
||||
"The delay for consecutive on-demand fetches": "The delay for consecutive on-demand fetches",
|
||||
"The files in this Vault are almost identical to the server's.": "The files in this Vault are almost identical to the server's.",
|
||||
|
||||
@@ -362,6 +362,7 @@ Export: Export
|
||||
"Failed to connect to the server: ${reason}": "Failed to connect to the server: ${reason}"
|
||||
Failed to connect to the server. Please check your settings.: Failed to connect to the server. Please check your settings.
|
||||
"Failed to connect to the signalling relay: ${reason}": "Failed to connect to the signalling relay: ${reason}"
|
||||
The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing.: The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing.
|
||||
Failed to create replicator instance.: Failed to create replicator instance.
|
||||
Failed to parse Setup-URI.: Failed to parse Setup-URI.
|
||||
"Failed:": "Failed:"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Run a finite operation with a flow-owned remote resource and release it
|
||||
* after either success or failure.
|
||||
*
|
||||
* Resource implementations make `dispose()` idempotent. This helper makes the
|
||||
* caller's ownership boundary explicit and prevents finite flows from leaking
|
||||
* a provider-owned resource when their operation rejects.
|
||||
*/
|
||||
export async function withOwnedRemoteResource<TResource extends { dispose(): Promise<void> }, TResult>(
|
||||
resource: TResource,
|
||||
operation: (ownedResource: TResource) => Promise<TResult>
|
||||
): Promise<TResult> {
|
||||
try {
|
||||
return await operation(resource);
|
||||
} finally {
|
||||
await resource.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withOwnedRemoteResource } from "./ownedRemoteResource";
|
||||
|
||||
describe("flow-owned remote resources", () => {
|
||||
it("disposes a resource after a successful finite operation", async () => {
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const resource = { dispose };
|
||||
|
||||
await expect(
|
||||
withOwnedRemoteResource(resource, async (owned) => (owned === resource ? "done" : "wrong"))
|
||||
).resolves.toBe("done");
|
||||
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disposes a resource when the finite operation rejects", async () => {
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const error = new Error("resource operation failed");
|
||||
|
||||
await expect(
|
||||
withOwnedRemoteResource({ dispose }, async () => {
|
||||
throw error;
|
||||
})
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
type EndpointProjection = readonly [kind: "url" | "invalid-url", value: string];
|
||||
|
||||
/**
|
||||
* Compare the effective endpoint rather than inconsequential URI spelling.
|
||||
* Fragments are not sent, query order is immaterial, and redundant trailing
|
||||
* slashes do not bind a different adapter. Invalid input is retained verbatim
|
||||
* and tagged so comparison remains deterministic and fails closed.
|
||||
*/
|
||||
function projectEndpoint(value: string): EndpointProjection {
|
||||
try {
|
||||
const endpoint = new URL(value);
|
||||
endpoint.hash = "";
|
||||
endpoint.searchParams.sort();
|
||||
while (endpoint.pathname.length > 1 && endpoint.pathname.endsWith("/")) {
|
||||
endpoint.pathname = endpoint.pathname.slice(0, -1);
|
||||
}
|
||||
return ["url", endpoint.toString()];
|
||||
} catch {
|
||||
return ["invalid-url", value];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the effective custom-header parser: trim each first name/value pair,
|
||||
* ignore incomplete lines, and let the last duplicate name win. Sorting the
|
||||
* resulting entries prevents line order alone from replacing a Replicator.
|
||||
*/
|
||||
function projectHeaders(value: string): readonly (readonly [name: string, value: string])[] {
|
||||
const headers = new Map<string, string>();
|
||||
for (const line of value.split("\n")) {
|
||||
const [name, headerValue] = line.split(":", 2).map((part) => part.trim());
|
||||
if (name && headerValue) {
|
||||
headers.set(name, headerValue);
|
||||
}
|
||||
}
|
||||
return [...headers.entries()].sort(([leftName, leftValue], [rightName, rightValue]) => {
|
||||
const nameOrder = leftName.localeCompare(rightName);
|
||||
return nameOrder || leftValue.localeCompare(rightValue);
|
||||
});
|
||||
}
|
||||
|
||||
function projectRemoteSecurity(settings: RemoteDBSettings) {
|
||||
return settings.encrypt
|
||||
? ([
|
||||
"encrypted",
|
||||
settings.passphrase,
|
||||
settings.useDynamicIterationCount,
|
||||
settings.E2EEAlgorithm,
|
||||
settings.permitEmptyPassphrase,
|
||||
] as const)
|
||||
: (["plain"] as const);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the effective CouchDB connection settings to a private comparison identity.
|
||||
* The returned value can contain credentials and must not be logged, persisted, or displayed.
|
||||
*/
|
||||
export function getCouchDBReplicatorConfigurationIdentity(settings: RemoteDBSettings): string {
|
||||
const authentication = settings.useJWT
|
||||
? ([
|
||||
"jwt",
|
||||
settings.jwtAlgorithm,
|
||||
settings.jwtKey,
|
||||
settings.jwtKid,
|
||||
settings.jwtSub,
|
||||
settings.jwtExpDuration,
|
||||
] as const)
|
||||
: (["basic", settings.couchDB_USER, settings.couchDB_PASSWORD] as const);
|
||||
return JSON.stringify([
|
||||
"couchdb",
|
||||
projectEndpoint(settings.couchDB_URI),
|
||||
settings.couchDB_DBNAME,
|
||||
authentication,
|
||||
projectHeaders(settings.couchDB_CustomHeaders),
|
||||
settings.useRequestAPI,
|
||||
settings.disableRequestURI,
|
||||
projectRemoteSecurity(settings),
|
||||
settings.enableCompression,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the effective Object Storage connection settings to a private comparison identity.
|
||||
* The returned value can contain credentials and must not be logged, persisted, or displayed.
|
||||
*/
|
||||
export function getObjectStorageReplicatorConfigurationIdentity(settings: RemoteDBSettings): string {
|
||||
return JSON.stringify([
|
||||
"s3",
|
||||
projectEndpoint(settings.endpoint),
|
||||
settings.bucket,
|
||||
settings.bucketPrefix,
|
||||
settings.region,
|
||||
settings.accessKey,
|
||||
settings.secretKey,
|
||||
settings.forcePathStyle,
|
||||
settings.useCustomRequestHandler,
|
||||
projectHeaders(settings.bucketCustomHeaders),
|
||||
projectRemoteSecurity(settings),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import {
|
||||
getCouchDBReplicatorConfigurationIdentity,
|
||||
getObjectStorageReplicatorConfigurationIdentity,
|
||||
} from "./replicatorConfigurationIdentity";
|
||||
|
||||
describe("active Replicator configuration identity", () => {
|
||||
function configuredSettings(overrides: Partial<ObsidianLiveSyncSettings> = {}): ObsidianLiveSyncSettings {
|
||||
return Object.assign(createNewVaultSettings(), {
|
||||
activeConfigurationId: "profile-a",
|
||||
couchDB_URI: "https://couch.example.test/base",
|
||||
couchDB_USER: "alice",
|
||||
couchDB_PASSWORD: "secret-a",
|
||||
couchDB_DBNAME: "vault",
|
||||
couchDB_CustomHeaders: "X-Second: two\nX-First: one",
|
||||
endpoint: "https://objects.example.test/base",
|
||||
accessKey: "alice",
|
||||
secretKey: "secret-a",
|
||||
bucket: "vault",
|
||||
bucketPrefix: "notes/",
|
||||
region: "auto",
|
||||
bucketCustomHeaders: "X-Second: two\nX-First: one",
|
||||
encrypt: true,
|
||||
passphrase: "encryption-a",
|
||||
useDynamicIterationCount: false,
|
||||
permitEmptyPassphrase: false,
|
||||
enableCompression: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
["couchDB_URI", "https://other.example.test/base"],
|
||||
["couchDB_DBNAME", "other-vault"],
|
||||
["couchDB_USER", "bob"],
|
||||
["couchDB_PASSWORD", "secret-b"],
|
||||
["couchDB_CustomHeaders", "X-First: changed"],
|
||||
["useRequestAPI", true],
|
||||
["disableRequestURI", true],
|
||||
["encrypt", false],
|
||||
["passphrase", "encryption-b"],
|
||||
["useDynamicIterationCount", true],
|
||||
["E2EEAlgorithm", ""],
|
||||
["permitEmptyPassphrase", true],
|
||||
["enableCompression", true],
|
||||
] satisfies Array<[keyof ObsidianLiveSyncSettings, ObsidianLiveSyncSettings[keyof ObsidianLiveSyncSettings]]>)(
|
||||
"detects a CouchDB %s change",
|
||||
(key, value) => {
|
||||
const settings = configuredSettings();
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...settings, [key]: value })).not.toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(settings)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("ignores persisted central profile identity when the effective connection settings match", () => {
|
||||
const settings = configuredSettings({ activeConfigurationId: "profile-a" });
|
||||
const otherProfile = { ...settings, activeConfigurationId: "profile-b" };
|
||||
|
||||
expect(getCouchDBReplicatorConfigurationIdentity(otherProfile)).toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(settings)
|
||||
);
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity(otherProfile)).toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(settings)
|
||||
);
|
||||
});
|
||||
|
||||
it("projects only the active CouchDB authentication mode", () => {
|
||||
const basic = configuredSettings({ useJWT: false, jwtKey: "inactive-a" });
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...basic, jwtKey: "inactive-b" })).toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(basic)
|
||||
);
|
||||
|
||||
const jwt = configuredSettings({
|
||||
useJWT: true,
|
||||
jwtAlgorithm: "HS256",
|
||||
jwtKey: "jwt-a",
|
||||
jwtKid: "kid-a",
|
||||
jwtSub: "subject-a",
|
||||
jwtExpDuration: 5,
|
||||
});
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...jwt, couchDB_PASSWORD: "inactive" })).toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(jwt)
|
||||
);
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...jwt, jwtKey: "jwt-b" })).not.toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(jwt)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["endpoint", "https://other.example.test/base"],
|
||||
["bucket", "other-vault"],
|
||||
["bucketPrefix", "archive/"],
|
||||
["region", "eu-west-1"],
|
||||
["accessKey", "bob"],
|
||||
["secretKey", "secret-b"],
|
||||
["forcePathStyle", false],
|
||||
["useCustomRequestHandler", true],
|
||||
["bucketCustomHeaders", "X-First: changed"],
|
||||
["encrypt", false],
|
||||
["passphrase", "encryption-b"],
|
||||
["useDynamicIterationCount", true],
|
||||
["E2EEAlgorithm", ""],
|
||||
["permitEmptyPassphrase", true],
|
||||
] satisfies Array<[keyof ObsidianLiveSyncSettings, ObsidianLiveSyncSettings[keyof ObsidianLiveSyncSettings]]>)(
|
||||
"detects an Object Storage %s change",
|
||||
(key, value) => {
|
||||
const settings = configuredSettings();
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity({ ...settings, [key]: value })).not.toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(settings)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("normalises endpoint and header representation without using the setup URI grammar", () => {
|
||||
const settings = configuredSettings();
|
||||
const couchIdentity = getCouchDBReplicatorConfigurationIdentity(settings);
|
||||
const objectStorageIdentity = getObjectStorageReplicatorConfigurationIdentity(settings);
|
||||
|
||||
expect(
|
||||
getCouchDBReplicatorConfigurationIdentity({
|
||||
...settings,
|
||||
couchDB_URI: "https://couch.example.test:443/base/",
|
||||
couchDB_CustomHeaders: "X-First: one\nX-Second: two",
|
||||
})
|
||||
).toBe(couchIdentity);
|
||||
expect(
|
||||
getObjectStorageReplicatorConfigurationIdentity({
|
||||
...settings,
|
||||
endpoint: "https://objects.example.test:443/base/",
|
||||
bucketCustomHeaders: "X-First: one\nX-Second: two",
|
||||
})
|
||||
).toBe(objectStorageIdentity);
|
||||
});
|
||||
|
||||
it("ignores inactive remote-security credentials", () => {
|
||||
const settings = configuredSettings({ encrypt: false, passphrase: "inactive-a" });
|
||||
|
||||
expect(
|
||||
getCouchDBReplicatorConfigurationIdentity({
|
||||
...settings,
|
||||
passphrase: "inactive-b",
|
||||
useDynamicIterationCount: !settings.useDynamicIterationCount,
|
||||
E2EEAlgorithm: "",
|
||||
permitEmptyPassphrase: !settings.permitEmptyPassphrase,
|
||||
})
|
||||
).toBe(getCouchDBReplicatorConfigurationIdentity(settings));
|
||||
expect(
|
||||
getObjectStorageReplicatorConfigurationIdentity({
|
||||
...settings,
|
||||
passphrase: "inactive-b",
|
||||
useDynamicIterationCount: !settings.useDynamicIterationCount,
|
||||
E2EEAlgorithm: "",
|
||||
permitEmptyPassphrase: !settings.permitEmptyPassphrase,
|
||||
})
|
||||
).toBe(getObjectStorageReplicatorConfigurationIdentity(settings));
|
||||
});
|
||||
|
||||
it("keeps malformed endpoints deterministic and scoped", () => {
|
||||
const settings = configuredSettings({ couchDB_URI: "not a URL", endpoint: "also not a URL" });
|
||||
|
||||
expect(() => getCouchDBReplicatorConfigurationIdentity(settings)).not.toThrow();
|
||||
expect(() => getObjectStorageReplicatorConfigurationIdentity(settings)).not.toThrow();
|
||||
expect(
|
||||
getCouchDBReplicatorConfigurationIdentity({ ...settings, couchDB_URI: "different invalid URL" })
|
||||
).not.toBe(getCouchDBReplicatorConfigurationIdentity(settings));
|
||||
const unrelatedPluginChange = { ...settings, displayLanguage: "ja" };
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity(unrelatedPluginChange)).toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(settings)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { REMOTE_COUCHDB, REMOTE_MINIO, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
CAPABILITY_NOT_APPLICABLE,
|
||||
CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
NO_INTERACTION,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
defineReplicatorProviderDefinitions,
|
||||
supportedOpenReplicationContinuous,
|
||||
replicationBlocked,
|
||||
replicationFailed,
|
||||
supportedStopActiveTransfer,
|
||||
supportedCapability,
|
||||
type ReplicatorProviderDefinitionMap,
|
||||
type ReplicationOutcome,
|
||||
type ReplicatorInstance,
|
||||
type UserInitiatedOneShotRunner,
|
||||
type UnattendedOneShotRunner,
|
||||
} 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 {
|
||||
getCouchDBReplicatorConfigurationIdentity,
|
||||
getObjectStorageReplicatorConfigurationIdentity,
|
||||
} from "./replicatorConfigurationIdentity";
|
||||
import {
|
||||
createCouchDBConnectionProbeFactory,
|
||||
createCouchDBPreferredTweakProbeFactory,
|
||||
createCouchDBSecuritySeedResourceFactory,
|
||||
createCouchDBSynchronisationInformationResourceFactory,
|
||||
createObjectStorageConnectionProbeFactory,
|
||||
createObjectStoragePreferredTweakProbeFactory,
|
||||
createObjectStorageSecuritySeedResourceFactory,
|
||||
} from "./replicatorResources";
|
||||
import {
|
||||
COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
} from "./centralRemoteAdministration";
|
||||
|
||||
/** Host environment sufficient to construct every current central provider. */
|
||||
export type CentralReplicatorProviderHost = LiveSyncCouchDBReplicatorEnv;
|
||||
|
||||
/** Minimal operation required by both central one-shot adapters. */
|
||||
interface OneShotOutcomeReplicator extends ReplicatorInstance {
|
||||
openOneShotReplicationWithOutcome(setting: RemoteDBSettings, showResult: boolean): Promise<ReplicationOutcome>;
|
||||
}
|
||||
|
||||
/** Narrow structurally so the shared adapter does not depend on either concrete provider class. */
|
||||
function isOneShotOutcomeReplicator(instance: ReplicatorInstance): instance is OneShotOutcomeReplicator {
|
||||
return (
|
||||
"openOneShotReplicationWithOutcome" in instance &&
|
||||
typeof instance.openOneShotReplicationWithOutcome === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function runOneShotWithOutcome(
|
||||
instance: ReplicatorInstance,
|
||||
setting: RemoteDBSettings,
|
||||
showResult: boolean
|
||||
): Promise<ReplicationOutcome> {
|
||||
if (!isOneShotOutcomeReplicator(instance)) {
|
||||
return replicationFailed(new Error("The configured provider does not implement one-shot replication."));
|
||||
}
|
||||
return await instance.openOneShotReplicationWithOutcome(setting, showResult);
|
||||
}
|
||||
|
||||
// Manual and unattended wrappers share the provider transfer operation, but
|
||||
// keep interaction authority and result presentation explicit at this boundary.
|
||||
const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
||||
return await runOneShotWithOutcome(
|
||||
instance,
|
||||
setting,
|
||||
request.interaction.kind === "permitted" && request.interaction.permissions.failureRecovery
|
||||
);
|
||||
};
|
||||
|
||||
const couchDBUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
||||
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
||||
return await runOneShotWithOutcome(instance, setting, false);
|
||||
};
|
||||
|
||||
const objectStorageUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
||||
return await runOneShotWithOutcome(
|
||||
instance,
|
||||
setting,
|
||||
request.interaction.kind === "permitted" && request.interaction.permissions.failureRecovery
|
||||
);
|
||||
};
|
||||
|
||||
const objectStorageUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
||||
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
||||
return await runOneShotWithOutcome(instance, setting, false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the complete, deliberately concrete central-provider matrix for one
|
||||
* LiveSync host. This closed composition is not a runtime provider registry.
|
||||
*/
|
||||
export function createCentralReplicatorProviderDefinitions(
|
||||
host: CentralReplicatorProviderHost
|
||||
): ReplicatorProviderDefinitionMap {
|
||||
return defineReplicatorProviderDefinitions([REMOTE_COUCHDB, REMOTE_MINIO] as const, {
|
||||
[REMOTE_COUCHDB]: {
|
||||
kind: REMOTE_COUCHDB,
|
||||
diagnosticName: "CouchDB",
|
||||
readiness: CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
isConfigured: (settings) =>
|
||||
settings.remoteType === REMOTE_COUCHDB &&
|
||||
!!settings.couchDB_URI?.trim() &&
|
||||
!!settings.couchDB_DBNAME?.trim(),
|
||||
configurationIdentity: getCouchDBReplicatorConfigurationIdentity,
|
||||
create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)),
|
||||
remoteResources: {
|
||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)),
|
||||
[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability(
|
||||
createCouchDBPreferredTweakProbeFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.SECURITY_SEED]: supportedCapability(
|
||||
createCouchDBSecuritySeedResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: supportedCapability(
|
||||
createCouchDBSynchronisationInformationResourceFactory(host)
|
||||
),
|
||||
},
|
||||
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot),
|
||||
unattendedOneShot: supportedCapability(couchDBUnattendedOneShot),
|
||||
continuous: supportedOpenReplicationContinuous(),
|
||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||
},
|
||||
[REMOTE_MINIO]: {
|
||||
kind: REMOTE_MINIO,
|
||||
diagnosticName: "Object Storage",
|
||||
readiness: CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
isConfigured: (settings) =>
|
||||
settings.remoteType === REMOTE_MINIO && !!settings.endpoint?.trim() && !!settings.bucket?.trim(),
|
||||
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
|
||||
create: () => Promise.resolve(new LiveSyncJournalReplicator(host)),
|
||||
remoteResources: {
|
||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(
|
||||
createObjectStorageConnectionProbeFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability(
|
||||
createObjectStoragePreferredTweakProbeFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.SECURITY_SEED]: supportedCapability(
|
||||
createObjectStorageSecuritySeedResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE,
|
||||
},
|
||||
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot),
|
||||
unattendedOneShot: supportedCapability(objectStorageUnattendedOneShot),
|
||||
continuous: CAPABILITY_NOT_APPLICABLE,
|
||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import {
|
||||
CAPABILITY_SUPPORT_KINDS,
|
||||
NO_INTERACTION,
|
||||
REPLICATION_COMPLETED,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
const constructorMocks = vi.hoisted(() => ({
|
||||
couchDB: vi.fn(),
|
||||
couchDBOneShot: vi.fn(async (..._args: unknown[]) => REPLICATION_COMPLETED),
|
||||
objectStorage: vi.fn(),
|
||||
objectStorageOneShot: vi.fn(async (..._args: unknown[]) => REPLICATION_COMPLETED),
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
||||
LiveSyncCouchDBReplicator: class {
|
||||
constructor(host: unknown) {
|
||||
constructorMocks.couchDB(host);
|
||||
}
|
||||
openOneShotReplicationWithOutcome(...args: unknown[]) {
|
||||
return constructorMocks.couchDBOneShot(...args);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => ({
|
||||
LiveSyncJournalReplicator: class {
|
||||
constructor(host: unknown) {
|
||||
constructorMocks.objectStorage(host);
|
||||
}
|
||||
openOneShotReplicationWithOutcome(...args: unknown[]) {
|
||||
return constructorMocks.objectStorageOneShot(...args);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { createCentralReplicatorProviderDefinitions } from "./replicatorProviders";
|
||||
|
||||
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());
|
||||
});
|
||||
|
||||
it("composes CouchDB and Object Storage policies outside LiveSyncBaseCore", async () => {
|
||||
const host = {} as Parameters<typeof createCentralReplicatorProviderDefinitions>[0];
|
||||
const definitions = createCentralReplicatorProviderDefinitions(host);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
||||
|
||||
expect([...definitions.keys()]).toEqual([REMOTE_COUCHDB, REMOTE_MINIO]);
|
||||
expect("sameKindReconciliation" in couchDB).toBe(false);
|
||||
expect("sameKindReconciliation" in objectStorage).toBe(false);
|
||||
|
||||
expect(
|
||||
couchDB.isConfigured(
|
||||
Object.assign(createNewVaultSettings(), {
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
couchDB_URI: "https://couch.example.test",
|
||||
couchDB_DBNAME: "vault",
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
objectStorage.isConfigured(
|
||||
Object.assign(createNewVaultSettings(), {
|
||||
remoteType: REMOTE_MINIO,
|
||||
endpoint: "https://objects.example.test",
|
||||
bucket: "vault",
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
await couchDB.create(createNewVaultSettings());
|
||||
await objectStorage.create(createNewVaultSettings());
|
||||
expect(constructorMocks.couchDB).toHaveBeenCalledWith(host);
|
||||
expect(constructorMocks.objectStorage).toHaveBeenCalledWith(host);
|
||||
});
|
||||
|
||||
it("rejects incomplete and wrong-kind settings before construction", () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
||||
|
||||
expect(couchDB.isConfigured(Object.assign(createNewVaultSettings(), { remoteType: REMOTE_MINIO }))).toBe(false);
|
||||
expect(
|
||||
objectStorage.isConfigured(Object.assign(createNewVaultSettings(), { remoteType: REMOTE_COUCHDB }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("declares the retained owned resources and cohesive optional administration", () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchResources = definitions.get(REMOTE_COUCHDB)?.remoteResources;
|
||||
const objectResources = definitions.get(REMOTE_MINIO)?.remoteResources;
|
||||
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());
|
||||
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(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(couchAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(objectAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect("activeRemoteReads" in definitions.get(REMOTE_COUCHDB)!).toBe(false);
|
||||
expect("fullTransfers" in definitions.get(REMOTE_COUCHDB)!).toBe(false);
|
||||
});
|
||||
|
||||
it("dispatches central finite work through provider-local attempt results", async () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
||||
const setting = createNewVaultSettings();
|
||||
const couchInstance = await couchDB.create(setting);
|
||||
const objectInstance = await objectStorage.create(setting);
|
||||
if (!couchInstance || !objectInstance) throw new Error("Provider construction failed");
|
||||
if (couchDB.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("CouchDB OneShot is unavailable");
|
||||
}
|
||||
if (objectStorage.unattendedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("Object Storage OneShot is unavailable");
|
||||
}
|
||||
|
||||
await expect(
|
||||
couchDB.userInitiatedOneShot.run(couchInstance, setting, {
|
||||
trigger: "manual",
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
})
|
||||
).resolves.toBe(REPLICATION_COMPLETED);
|
||||
await expect(
|
||||
objectStorage.unattendedOneShot.run(objectInstance, setting, {
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
})
|
||||
).resolves.toBe(REPLICATION_COMPLETED);
|
||||
|
||||
expect(constructorMocks.couchDBOneShot).toHaveBeenCalledWith(setting, true);
|
||||
expect(constructorMocks.objectStorageOneShot).toHaveBeenCalledWith(setting, false);
|
||||
});
|
||||
|
||||
it("dispatches central finite work through the declared operation rather than constructor identity", async () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
||||
const setting = createNewVaultSettings();
|
||||
const createStructuralOneShotReplicator = () => ({
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
openReplication: vi.fn(async () => true),
|
||||
terminateSync: vi.fn(),
|
||||
closeReplication: vi.fn(),
|
||||
openOneShotReplicationWithOutcome: vi.fn(async () => REPLICATION_COMPLETED),
|
||||
});
|
||||
const couchInstance = createStructuralOneShotReplicator();
|
||||
const objectStorageInstance = createStructuralOneShotReplicator();
|
||||
if (couchDB.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("CouchDB OneShot is unavailable");
|
||||
}
|
||||
if (objectStorage.unattendedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("Object Storage OneShot is unavailable");
|
||||
}
|
||||
|
||||
const couchOutcome = await couchDB.userInitiatedOneShot.run(couchInstance, setting, {
|
||||
trigger: "manual",
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
const objectStorageOutcome = await objectStorage.unattendedOneShot.run(objectStorageInstance, setting, {
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
|
||||
expect.soft(couchOutcome).toBe(REPLICATION_COMPLETED);
|
||||
expect.soft(objectStorageOutcome).toBe(REPLICATION_COMPLETED);
|
||||
expect(couchInstance.openOneShotReplicationWithOutcome).toHaveBeenCalledWith(setting, true);
|
||||
expect(objectStorageInstance.openOneShotReplicationWithOutcome).toHaveBeenCalledWith(setting, false);
|
||||
});
|
||||
|
||||
it("rejects a one-shot adapter whose Replicator does not declare the required operation", async () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
const setting = createNewVaultSettings();
|
||||
const incompleteInstance = {
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
openReplication: vi.fn(async () => true),
|
||||
terminateSync: vi.fn(),
|
||||
closeReplication: vi.fn(),
|
||||
};
|
||||
if (couchDB.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("CouchDB OneShot is unavailable");
|
||||
}
|
||||
|
||||
const outcome = await couchDB.userInitiatedOneShot.run(incompleteInstance, setting, {
|
||||
trigger: "manual",
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
|
||||
expect(outcome.status).toBe("failed");
|
||||
expect(incompleteInstance.openReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { LOG_LEVEL_NOTICE, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
logger: vi.fn(),
|
||||
couchDB: [] as Array<{
|
||||
host: unknown;
|
||||
isMobile: ReturnType<typeof vi.fn>;
|
||||
connectRemoteCouchDBWithSetting: ReturnType<typeof vi.fn>;
|
||||
getRemoteStatus: ReturnType<typeof vi.fn>;
|
||||
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
||||
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
||||
closeReplication: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
objectStorage: [] as Array<{
|
||||
host: unknown;
|
||||
tryConnectRemote: ReturnType<typeof vi.fn>;
|
||||
getRemoteStatus: ReturnType<typeof vi.fn>;
|
||||
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
||||
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
||||
closeReplication: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
checkSyncInfo: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/compat/common/logger")>();
|
||||
return { ...actual, Logger: mocks.logger };
|
||||
});
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => ({
|
||||
checkSyncInfo: mocks.checkSyncInfo,
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
||||
LiveSyncCouchDBReplicator: class {
|
||||
host: unknown;
|
||||
isMobile = vi.fn(() => false);
|
||||
connectRemoteCouchDBWithSetting = vi.fn();
|
||||
getRemoteStatus = vi.fn();
|
||||
getRemotePreferredTweakValues = vi.fn();
|
||||
getReplicationPBKDF2Salt = vi.fn();
|
||||
closeReplication = vi.fn();
|
||||
|
||||
constructor(host: unknown) {
|
||||
this.host = host;
|
||||
mocks.couchDB.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => ({
|
||||
LiveSyncJournalReplicator: class {
|
||||
host: unknown;
|
||||
tryConnectRemote = vi.fn();
|
||||
getRemoteStatus = vi.fn();
|
||||
getRemotePreferredTweakValues = vi.fn();
|
||||
getReplicationPBKDF2Salt = vi.fn();
|
||||
closeReplication = vi.fn();
|
||||
|
||||
constructor(host: unknown) {
|
||||
this.host = host;
|
||||
mocks.objectStorage.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
createCouchDBConnectionProbeFactory,
|
||||
createCouchDBPreferredTweakProbeFactory,
|
||||
createCouchDBSecuritySeedResourceFactory,
|
||||
createCouchDBSynchronisationInformationResourceFactory,
|
||||
createObjectStorageConnectionProbeFactory,
|
||||
createObjectStoragePreferredTweakProbeFactory,
|
||||
createObjectStorageSecuritySeedResourceFactory,
|
||||
} from "./replicatorResources";
|
||||
|
||||
function createSettings(overrides: Partial<ObsidianLiveSyncSettings> = {}): ObsidianLiveSyncSettings {
|
||||
return Object.assign(createNewVaultSettings(), {
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
couchDB_URI: "https://couch.example.test",
|
||||
couchDB_DBNAME: "vault",
|
||||
endpoint: "https://objects.example.test",
|
||||
bucket: "vault",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("replicator probe factories", () => {
|
||||
beforeEach(() => {
|
||||
mocks.couchDB.length = 0;
|
||||
mocks.objectStorage.length = 0;
|
||||
mocks.checkSyncInfo.mockReset().mockResolvedValue(true);
|
||||
mocks.logger.mockClear();
|
||||
});
|
||||
|
||||
it("binds a CouchDB connection probe to a shallow settings snapshot and closes its owned connection", async () => {
|
||||
const host = { name: "host" };
|
||||
const source = createSettings();
|
||||
const snapshot = { ...source };
|
||||
const probe = await createCouchDBConnectionProbeFactory(host as never)(source);
|
||||
const replicator = mocks.couchDB[0];
|
||||
const close = vi.fn(async () => undefined);
|
||||
const databaseClose = vi.fn(async () => undefined);
|
||||
replicator.isMobile.mockReturnValue(true);
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({
|
||||
db: { close: databaseClose },
|
||||
info: {},
|
||||
close,
|
||||
});
|
||||
|
||||
source.couchDB_URI = "https://changed.example.test";
|
||||
expect(await probe.check({ createIfMissing: false, showResult: true })).toEqual({ ok: true });
|
||||
|
||||
expect(replicator.connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(snapshot, true, false, false);
|
||||
expect(replicator.connectRemoteCouchDBWithSetting.mock.calls[0][0]).not.toBe(source);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(databaseClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a CouchDB connection error string and delegates status to the same snapshot", async () => {
|
||||
const source = createSettings();
|
||||
const snapshot = { ...source };
|
||||
const probe = await createCouchDBConnectionProbeFactory({} as never)(source);
|
||||
const replicator = mocks.couchDB[0];
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue("connection failed");
|
||||
|
||||
expect(await probe.check()).toEqual({ ok: false, reason: "connection failed" });
|
||||
|
||||
const status = { estimatedSize: 12 };
|
||||
replicator.getRemoteStatus.mockResolvedValue(status);
|
||||
source.couchDB_DBNAME = "changed-vault";
|
||||
expect(await probe.getStatus()).toBe(status);
|
||||
expect(replicator.getRemoteStatus).toHaveBeenCalledWith(snapshot);
|
||||
});
|
||||
|
||||
it("emits a result Notice only for an explicitly visible successful CouchDB probe", async () => {
|
||||
const probe = await createCouchDBConnectionProbeFactory({} as never)(createSettings());
|
||||
const replicator = mocks.couchDB[0];
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({
|
||||
info: { db_name: "vault" },
|
||||
close: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
await expect(probe.check({ showResult: true })).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(mocks.logger).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger).toHaveBeenCalledWith("Connected to vault successfully", LOG_LEVEL_NOTICE);
|
||||
|
||||
mocks.logger.mockClear();
|
||||
await expect(probe.check()).resolves.toEqual({ ok: true });
|
||||
expect(mocks.logger).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits a result Notice only for an explicitly visible CouchDB connection failure", async () => {
|
||||
const reason = "connection failed";
|
||||
const translatedFailure = "translated CouchDB connection failure";
|
||||
const translate = vi.fn(() => translatedFailure);
|
||||
const settings = createSettings();
|
||||
const probe = await createCouchDBConnectionProbeFactory({ services: { context: { translate } } } as never)(
|
||||
settings
|
||||
);
|
||||
const replicator = mocks.couchDB[0];
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue(reason);
|
||||
|
||||
await expect(probe.check({ showResult: true })).resolves.toEqual({ ok: false, reason });
|
||||
|
||||
expect(mocks.logger).toHaveBeenCalledTimes(1);
|
||||
expect(translate).toHaveBeenCalledWith("liveSyncReplicator.couldNotConnectTo", {
|
||||
uri: settings.couchDB_URI,
|
||||
name: settings.couchDB_DBNAME,
|
||||
db: reason,
|
||||
});
|
||||
expect(mocks.logger).toHaveBeenCalledWith(translatedFailure, LOG_LEVEL_NOTICE);
|
||||
|
||||
mocks.logger.mockClear();
|
||||
translate.mockClear();
|
||||
await expect(probe.check()).resolves.toEqual({ ok: false, reason });
|
||||
expect(mocks.logger).not.toHaveBeenCalled();
|
||||
expect(translate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates an unpublished Object Storage replicator for each probe and normalises connection results", async () => {
|
||||
const host = { name: "host" };
|
||||
const source = createSettings({ remoteType: REMOTE_MINIO });
|
||||
const snapshot = { ...source };
|
||||
const factory = createObjectStorageConnectionProbeFactory(host as never);
|
||||
const firstProbe = await factory(source);
|
||||
const secondProbe = await factory(source);
|
||||
expect(mocks.objectStorage).toHaveLength(2);
|
||||
|
||||
const firstReplicator = mocks.objectStorage[0];
|
||||
firstReplicator.tryConnectRemote.mockResolvedValue(true);
|
||||
source.endpoint = "https://changed.example.test";
|
||||
expect(await firstProbe.check()).toEqual({ ok: true });
|
||||
expect(firstReplicator.tryConnectRemote).toHaveBeenCalledWith(snapshot, false);
|
||||
|
||||
const secondReplicator = mocks.objectStorage[1];
|
||||
secondReplicator.tryConnectRemote.mockResolvedValue(false);
|
||||
expect(await secondProbe.check({ showResult: true })).toEqual({ ok: false });
|
||||
expect(secondReplicator.tryConnectRemote).toHaveBeenCalledWith(snapshot, true);
|
||||
|
||||
const error = new Error("storage offline");
|
||||
secondReplicator.tryConnectRemote.mockRejectedValue(error);
|
||||
expect(await secondProbe.check()).toEqual({ ok: false, reason: error });
|
||||
});
|
||||
|
||||
it("delegates Object Storage status and preferred-tweak reads to the trial snapshot", async () => {
|
||||
const source = createSettings({ remoteType: REMOTE_MINIO });
|
||||
const snapshot = { ...source };
|
||||
const connectionProbe = await createObjectStorageConnectionProbeFactory({} as never)(source);
|
||||
const preferredProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)(source);
|
||||
const connectionReplicator = mocks.objectStorage[0];
|
||||
const preferredReplicator = mocks.objectStorage[1];
|
||||
const status = { estimatedSize: 42 };
|
||||
const preferred = { status: "unsupported" } as const;
|
||||
connectionReplicator.getRemoteStatus.mockResolvedValue(status);
|
||||
preferredReplicator.getRemotePreferredTweakValues.mockResolvedValue(preferred);
|
||||
|
||||
source.bucket = "changed-vault";
|
||||
expect(await connectionProbe.getStatus()).toBe(status);
|
||||
expect(await preferredProbe.read()).toBe(preferred);
|
||||
expect(connectionReplicator.getRemoteStatus).toHaveBeenCalledWith(snapshot);
|
||||
expect(preferredReplicator.getRemotePreferredTweakValues).toHaveBeenCalledWith(snapshot);
|
||||
});
|
||||
|
||||
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)(
|
||||
createSettings({ remoteType: REMOTE_MINIO })
|
||||
);
|
||||
const couchReplicator = mocks.couchDB[0];
|
||||
const objectReplicator = mocks.objectStorage[0];
|
||||
|
||||
const couchDisposal = couchProbe.dispose();
|
||||
expect(couchProbe.dispose()).toBe(couchDisposal);
|
||||
const objectDisposal = objectProbe.dispose();
|
||||
expect(objectProbe.dispose()).toBe(objectDisposal);
|
||||
await Promise.all([couchDisposal, objectDisposal]);
|
||||
expect(couchReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
expect(objectReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shares a rejected disposal promise and never retries closeReplication", async () => {
|
||||
const probe = await createObjectStorageConnectionProbeFactory({} as never)(
|
||||
createSettings({ remoteType: REMOTE_MINIO })
|
||||
);
|
||||
const replicator = mocks.objectStorage[0];
|
||||
const failure = new Error("close failed");
|
||||
replicator.closeReplication.mockImplementation(() => {
|
||||
throw failure;
|
||||
});
|
||||
|
||||
const disposal = probe.dispose();
|
||||
expect(probe.dispose()).toBe(disposal);
|
||||
await expect(disposal).rejects.toBe(failure);
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reads the Security Seed from a settings snapshot and disposes its private Replicator", async () => {
|
||||
const couchSettings = createSettings();
|
||||
const couchSnapshot = { ...couchSettings };
|
||||
const objectSettings = createSettings({ remoteType: REMOTE_MINIO });
|
||||
const objectSnapshot = { ...objectSettings };
|
||||
const couchResource = await createCouchDBSecuritySeedResourceFactory({} as never)(couchSettings);
|
||||
const objectResource = await createObjectStorageSecuritySeedResourceFactory({} as never)(objectSettings);
|
||||
const couchReplicator = mocks.couchDB[0];
|
||||
const objectReplicator = mocks.objectStorage[0];
|
||||
const couchSeed = new Uint8Array([1]);
|
||||
const objectSeed = new Uint8Array([2]);
|
||||
couchReplicator.getReplicationPBKDF2Salt.mockResolvedValue(couchSeed);
|
||||
objectReplicator.getReplicationPBKDF2Salt.mockResolvedValue(objectSeed);
|
||||
|
||||
couchSettings.couchDB_URI = "https://changed.example.test";
|
||||
objectSettings.endpoint = "https://changed.example.test";
|
||||
await expect(couchResource.read()).resolves.toBe(couchSeed);
|
||||
await expect(objectResource.read()).resolves.toBe(objectSeed);
|
||||
expect(couchReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledWith(couchSnapshot, true);
|
||||
expect(objectReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledWith(objectSnapshot, true);
|
||||
|
||||
await Promise.all([couchResource.dispose(), objectResource.dispose()]);
|
||||
expect(couchReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
expect(objectReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("checks synchronisation information through an owned connection and disposes the private Replicator", async () => {
|
||||
const settings = createSettings();
|
||||
const snapshot = { ...settings };
|
||||
const resource = await createCouchDBSynchronisationInformationResourceFactory({} as never)(settings);
|
||||
const replicator = mocks.couchDB[0];
|
||||
const database = { close: vi.fn() };
|
||||
const close = vi.fn(async () => undefined);
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({ db: database, close });
|
||||
|
||||
settings.couchDB_DBNAME = "changed-vault";
|
||||
await expect(resource.check()).resolves.toBe(true);
|
||||
expect(replicator.connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(snapshot, false, true);
|
||||
expect(mocks.checkSyncInfo).toHaveBeenCalledWith(database);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(database.close).not.toHaveBeenCalled();
|
||||
|
||||
await resource.dispose();
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves a CouchDB connection or setup failure for the settings flow to report", async () => {
|
||||
const reason = "connection failed";
|
||||
const resource = await createCouchDBSynchronisationInformationResourceFactory({} as never)(createSettings());
|
||||
const replicator = mocks.couchDB[0];
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue(reason);
|
||||
|
||||
await expect(resource.check()).rejects.toMatchObject({ message: reason });
|
||||
});
|
||||
|
||||
it("closes the owned connection when synchronisation-information verification rejects", async () => {
|
||||
const resource = await createCouchDBSynchronisationInformationResourceFactory({} as never)(createSettings());
|
||||
const replicator = mocks.couchDB[0];
|
||||
const database = { close: vi.fn() };
|
||||
const close = vi.fn(async () => undefined);
|
||||
const failure = new Error("verification failed");
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({ db: database, close });
|
||||
mocks.checkSyncInfo.mockRejectedValue(failure);
|
||||
|
||||
await expect(resource.check()).rejects.toBe(failure);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(database.close).not.toHaveBeenCalled();
|
||||
|
||||
await resource.dispose();
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type {
|
||||
ConnectionProbeFactory,
|
||||
RemoteConnectionProbe,
|
||||
RemoteConnectionProbeOptions,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
LiveSyncCouchDBReplicator,
|
||||
type LiveSyncCouchDBReplicatorEnv,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared";
|
||||
|
||||
/** Host environment sufficient to construct either central connection probe. */
|
||||
export type ConnectionResourceHost = LiveSyncCouchDBReplicatorEnv;
|
||||
|
||||
function createCouchDBConnectionProbe(
|
||||
replicator: LiveSyncCouchDBReplicator,
|
||||
snapshot: RemoteDBSettings,
|
||||
host: ConnectionResourceHost
|
||||
): RemoteConnectionProbe {
|
||||
const dispose = createReplicatorDisposer(replicator);
|
||||
return {
|
||||
check: async (options: RemoteConnectionProbeOptions = {}) => {
|
||||
const connection = await replicator.connectRemoteCouchDBWithSetting(
|
||||
snapshot,
|
||||
replicator.isMobile(),
|
||||
options.createIfMissing ?? true,
|
||||
false
|
||||
);
|
||||
if (typeof connection === "string") {
|
||||
if (options.showResult) {
|
||||
Logger(
|
||||
host.services.context.translate("liveSyncReplicator.couldNotConnectTo", {
|
||||
uri: snapshot.couchDB_URI,
|
||||
name: snapshot.couchDB_DBNAME,
|
||||
db: connection,
|
||||
}),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
return { ok: false, reason: connection };
|
||||
}
|
||||
try {
|
||||
if (options.showResult) {
|
||||
Logger(`Connected to ${connection.info.db_name} successfully`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return { ok: true };
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
},
|
||||
getStatus: () => replicator.getRemoteStatus(snapshot),
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
function createObjectStorageConnectionProbe(
|
||||
replicator: LiveSyncJournalReplicator,
|
||||
snapshot: RemoteDBSettings
|
||||
): RemoteConnectionProbe {
|
||||
const dispose = createReplicatorDisposer(replicator);
|
||||
return {
|
||||
check: async (options: RemoteConnectionProbeOptions = {}) => {
|
||||
try {
|
||||
const connected = await replicator.tryConnectRemote(snapshot, options.showResult ?? false);
|
||||
return connected ? { ok: true } : { ok: false };
|
||||
} catch (error) {
|
||||
return { ok: false, reason: error };
|
||||
}
|
||||
},
|
||||
getStatus: () => replicator.getRemoteStatus(snapshot),
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an unpublished CouchDB connection probe for one host.
|
||||
*
|
||||
* The probe owns both its concrete Replicator and each connection it opens. It
|
||||
* never publishes that Replicator as the active provider instance. A caller
|
||||
* may request the established result Notice explicitly; ordinary probes remain
|
||||
* silent.
|
||||
*/
|
||||
export function createCouchDBConnectionProbeFactory(host: ConnectionResourceHost): ConnectionProbeFactory {
|
||||
return (setting) => {
|
||||
const snapshot = snapshotRemoteSettings(setting);
|
||||
return Promise.resolve(createCouchDBConnectionProbe(new LiveSyncCouchDBReplicator(host), snapshot, host));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an unpublished Object Storage connection probe for one host.
|
||||
*
|
||||
* The probe owns its concrete Replicator and never publishes or replaces the
|
||||
* active provider instance.
|
||||
*/
|
||||
export function createObjectStorageConnectionProbeFactory(host: ConnectionResourceHost): ConnectionProbeFactory {
|
||||
return (setting) => {
|
||||
const snapshot = snapshotRemoteSettings(setting);
|
||||
return Promise.resolve(createObjectStorageConnectionProbe(new LiveSyncJournalReplicator(host), snapshot));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
createCouchDBConnectionProbeFactory,
|
||||
createObjectStorageConnectionProbeFactory,
|
||||
type ConnectionResourceHost,
|
||||
} from "./connection";
|
||||
export {
|
||||
createCouchDBPreferredTweakProbeFactory,
|
||||
createObjectStoragePreferredTweakProbeFactory,
|
||||
type PreferredTweakResourceHost,
|
||||
} from "./preferredTweak";
|
||||
export {
|
||||
createCouchDBSecuritySeedResourceFactory,
|
||||
createObjectStorageSecuritySeedResourceFactory,
|
||||
type SecuritySeedResourceHost,
|
||||
} from "./securitySeed";
|
||||
export { createCouchDBSynchronisationInformationResourceFactory } from "./synchronisationInformation";
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { RemoteDBSettings, RemotePreferredTweakResult } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { PreferredTweakProbe, PreferredTweakProbeFactory } 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";
|
||||
|
||||
/** Host environment sufficient to construct either preferred-tweak probe. */
|
||||
export type PreferredTweakResourceHost = LiveSyncCouchDBReplicatorEnv;
|
||||
|
||||
interface PreferredTweakReplicator extends ResourceReplicator {
|
||||
getRemotePreferredTweakValues(setting: RemoteDBSettings): Promise<RemotePreferredTweakResult>;
|
||||
}
|
||||
|
||||
function createPreferredTweakProbe(
|
||||
replicator: PreferredTweakReplicator,
|
||||
snapshot: RemoteDBSettings
|
||||
): PreferredTweakProbe {
|
||||
return {
|
||||
read: () => replicator.getRemotePreferredTweakValues(snapshot),
|
||||
dispose: createReplicatorDisposer(replicator),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an unpublished, independently disposed CouchDB preferred-tweak probe. */
|
||||
export function createCouchDBPreferredTweakProbeFactory(host: PreferredTweakResourceHost): PreferredTweakProbeFactory {
|
||||
return (setting) => {
|
||||
const snapshot = snapshotRemoteSettings(setting);
|
||||
return Promise.resolve(createPreferredTweakProbe(new LiveSyncCouchDBReplicator(host), snapshot));
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an unpublished, independently disposed Object Storage preferred-tweak probe. */
|
||||
export function createObjectStoragePreferredTweakProbeFactory(
|
||||
host: PreferredTweakResourceHost
|
||||
): PreferredTweakProbeFactory {
|
||||
return (setting) => {
|
||||
const snapshot = snapshotRemoteSettings(setting);
|
||||
return Promise.resolve(createPreferredTweakProbe(new LiveSyncJournalReplicator(host), snapshot));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { SecuritySeedResourceFactory } 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";
|
||||
|
||||
/** Host environment sufficient to construct either Security Seed resource. */
|
||||
export type SecuritySeedResourceHost = LiveSyncCouchDBReplicatorEnv;
|
||||
|
||||
/** Minimal private Replicator surface required by a Security Seed resource. */
|
||||
interface SecuritySeedReplicator extends ResourceReplicator {
|
||||
getReplicationPBKDF2Salt(setting: RemoteDBSettings, refresh?: boolean): Promise<Uint8Array<ArrayBuffer>>;
|
||||
}
|
||||
|
||||
function createSecuritySeedResourceFactory(
|
||||
createReplicator: () => SecuritySeedReplicator
|
||||
): SecuritySeedResourceFactory {
|
||||
return (setting) => {
|
||||
const snapshot = snapshotRemoteSettings(setting);
|
||||
const replicator = createReplicator();
|
||||
return Promise.resolve({
|
||||
read: () => replicator.getReplicationPBKDF2Salt(snapshot, true),
|
||||
dispose: createReplicatorDisposer(replicator),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an unpublished, independently disposed CouchDB Security Seed resource. */
|
||||
export function createCouchDBSecuritySeedResourceFactory(host: SecuritySeedResourceHost): SecuritySeedResourceFactory {
|
||||
return createSecuritySeedResourceFactory(() => new LiveSyncCouchDBReplicator(host));
|
||||
}
|
||||
|
||||
/** Build an unpublished, independently disposed Object Storage Security Seed resource. */
|
||||
export function createObjectStorageSecuritySeedResourceFactory(
|
||||
host: SecuritySeedResourceHost
|
||||
): SecuritySeedResourceFactory {
|
||||
return createSecuritySeedResourceFactory(() => new LiveSyncJournalReplicator(host));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
/**
|
||||
* Closeable surface of a concrete Replicator owned by one private resource.
|
||||
*
|
||||
* It deliberately exposes no active-provider controls: the resource may use
|
||||
* the helper for one bounded operation, then must dispose it without
|
||||
* publishing or replacing the active Replicator.
|
||||
*/
|
||||
export interface ResourceReplicator {
|
||||
closeReplication(): void | Promise<void>;
|
||||
}
|
||||
|
||||
/** Create one idempotent asynchronous disposer for a private Replicator. */
|
||||
export function createReplicatorDisposer(replicator: ResourceReplicator): () => Promise<void> {
|
||||
let disposal: Promise<void> | undefined;
|
||||
return () => {
|
||||
if (disposal === undefined) {
|
||||
disposal = Promise.resolve().then(() => replicator.closeReplication());
|
||||
}
|
||||
return disposal;
|
||||
};
|
||||
}
|
||||
|
||||
/** Fence a finite resource from later edits to its source settings object. */
|
||||
export function snapshotRemoteSettings(setting: RemoteDBSettings): RemoteDBSettings {
|
||||
return { ...setting };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { SynchronisationInformationResourceFactory } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
LiveSyncCouchDBReplicator,
|
||||
type LiveSyncCouchDBReplicatorEnv,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { checkSyncInfo } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
|
||||
import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared";
|
||||
|
||||
/**
|
||||
* Build an unpublished CouchDB synchronisation-information verifier.
|
||||
*
|
||||
* The resource owns its concrete Replicator and connection, and cannot replace
|
||||
* the active provider instance. Its check resolves to `false` only for observed
|
||||
* incompatibility; connection, setup, and verification failures reject so the
|
||||
* caller can report an operational failure separately.
|
||||
*/
|
||||
export function createCouchDBSynchronisationInformationResourceFactory(
|
||||
host: LiveSyncCouchDBReplicatorEnv
|
||||
): SynchronisationInformationResourceFactory {
|
||||
return (setting) => {
|
||||
const snapshot = snapshotRemoteSettings(setting);
|
||||
const replicator = new LiveSyncCouchDBReplicator(host);
|
||||
return Promise.resolve({
|
||||
check: async () => {
|
||||
const connection = await replicator.connectRemoteCouchDBWithSetting(
|
||||
snapshot,
|
||||
replicator.isMobile(),
|
||||
true
|
||||
);
|
||||
if (typeof connection === "string") {
|
||||
throw new Error(connection);
|
||||
}
|
||||
try {
|
||||
return await checkSyncInfo(connection.db);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
},
|
||||
dispose: createReplicatorDisposer(replicator),
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { USER_INITIATED_REPLICATION_AUTHORITY } from "@vrtmrz/livesync-commonlib/replication";
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
export let core :LiveSyncBaseCore;
|
||||
// $: core = plugin.core;
|
||||
@@ -104,7 +105,10 @@
|
||||
await requestUpdate();
|
||||
}
|
||||
async function replicate() {
|
||||
await core.services.replication.replicate(true);
|
||||
await core.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
}
|
||||
function selectAllNewest(selectMode: boolean) {
|
||||
selectNewestPulse++;
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 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";
|
||||
import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites";
|
||||
@@ -29,6 +30,31 @@ type NoteDocumentID = DocumentID;
|
||||
type Rev = string;
|
||||
|
||||
type ChunkUsageMap = Map<NoteDocumentID, Map<Rev, Set<ChunkID>>>;
|
||||
|
||||
type CouchDBCompactionReplicator = ReplicatorInstance &
|
||||
Pick<LiveSyncCouchDBReplicator, "connectRemoteCouchDBWithSetting">;
|
||||
|
||||
type CouchDBGarbageCollectionReplicator = ReplicatorInstance &
|
||||
Pick<LiveSyncCouchDBReplicator, "getConnectedDeviceList" | "openOneShotReplication">;
|
||||
|
||||
function canCompactCouchDBRemote(replicator: ReplicatorInstance): replicator is CouchDBCompactionReplicator {
|
||||
return (
|
||||
"connectRemoteCouchDBWithSetting" in replicator &&
|
||||
typeof replicator.connectRemoteCouchDBWithSetting === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function canRunCouchDBGarbageCollection(
|
||||
replicator: ReplicatorInstance
|
||||
): replicator is CouchDBGarbageCollectionReplicator {
|
||||
return (
|
||||
"getConnectedDeviceList" in replicator &&
|
||||
typeof replicator.getConnectedDeviceList === "function" &&
|
||||
"openOneShotReplication" in replicator &&
|
||||
typeof replicator.openOneShotReplication === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalDatabaseMaintenance extends LiveSyncCommands {
|
||||
onunload(): void {
|
||||
// NO OP.
|
||||
@@ -737,7 +763,8 @@ Success: ${successCount}, Errored: ${errored}`;
|
||||
}
|
||||
|
||||
async compactDatabase() {
|
||||
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
|
||||
const replicator = this.core.replicator;
|
||||
if (!canCompactCouchDBRemote(replicator)) return;
|
||||
const remote = await replicator.connectRemoteCouchDBWithSetting(this.settings, false, false, true);
|
||||
if (!remote) {
|
||||
this._notice("Failed to connect to remote for compaction.", "gc-compact");
|
||||
@@ -840,8 +867,9 @@ Success: ${successCount}, Errored: ${errored}`;
|
||||
// }
|
||||
// }
|
||||
async gcv3() {
|
||||
const replicator = this.core.replicator;
|
||||
if (this.settings.remoteType !== REMOTE_COUCHDB || !canRunCouchDBGarbageCollection(replicator)) return;
|
||||
if (!(await this.ensureAvailable("Garbage Collection"))) return;
|
||||
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
|
||||
// Start one-shot replication to ensure all changes are synced before GC.
|
||||
const r0 = await replicator.openOneShotReplication(this.settings, false, false, "sync");
|
||||
if (!r0) {
|
||||
@@ -854,7 +882,7 @@ Success: ${successCount}, Errored: ${errored}`;
|
||||
// Delete the chunk, but first verify the following:
|
||||
// Fetch the list of accepted nodes from the replicator.
|
||||
const OPTION_CANCEL = "Cancel Garbage Collection";
|
||||
const info = await this.core.replicator.getConnectedDeviceList();
|
||||
const info = await replicator.getConnectedDeviceList();
|
||||
if (!info) {
|
||||
this._notice("No connected device information found. Cancelling Garbage Collection.");
|
||||
return;
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { App, Modal } from "@/deps.ts";
|
||||
import P2POpenReplicationPane from "./P2POpenReplicationPane.svelte";
|
||||
import { mount, unmount } from "svelte";
|
||||
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
|
||||
/**
|
||||
* Reports action completion so the pane does not infer success merely from a
|
||||
* settled Promise.
|
||||
*/
|
||||
export type P2POpenReplicationModalCallback = {
|
||||
onSync: (peerId: string) => Promise<void>;
|
||||
onSyncAndClose: (peerId: string) => Promise<void>;
|
||||
onSync: (peerId: string) => Promise<boolean>;
|
||||
onSyncAndClose: (peerId: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export class P2POpenReplicationModal extends Modal {
|
||||
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||
p2p: P2PServiceViews;
|
||||
callback?: P2POpenReplicationModalCallback;
|
||||
component?: ReturnType<typeof mount>;
|
||||
showResult: boolean;
|
||||
@@ -19,7 +23,7 @@ export class P2POpenReplicationModal extends Modal {
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
liveSyncReplicator: LiveSyncTrysteroReplicator,
|
||||
p2p: P2PServiceViews,
|
||||
callback?: P2POpenReplicationModalCallback,
|
||||
showResult: boolean = false,
|
||||
title: string = "P2P Replication",
|
||||
@@ -27,7 +31,7 @@ export class P2POpenReplicationModal extends Modal {
|
||||
rebuildMode: boolean = false
|
||||
) {
|
||||
super(app);
|
||||
this.liveSyncReplicator = liveSyncReplicator;
|
||||
this.p2p = p2p;
|
||||
this.callback = callback;
|
||||
this.showResult = showResult;
|
||||
this.title = title;
|
||||
@@ -35,17 +39,20 @@ export class P2POpenReplicationModal extends Modal {
|
||||
this.rebuildMode = rebuildMode;
|
||||
}
|
||||
|
||||
async onSync(peerId: string) {
|
||||
async onSync(peerId: string): Promise<boolean> {
|
||||
if (this.callback?.onSync) {
|
||||
await this.callback.onSync(peerId);
|
||||
return await this.callback.onSync(peerId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async onSyncAndClose(peerId: string) {
|
||||
async onSyncAndClose(peerId: string): Promise<boolean> {
|
||||
let completed = false;
|
||||
if (this.callback?.onSyncAndClose) {
|
||||
await this.callback.onSyncAndClose(peerId);
|
||||
completed = await this.callback.onSyncAndClose(peerId);
|
||||
}
|
||||
this.close();
|
||||
return completed;
|
||||
}
|
||||
|
||||
override onOpen() {
|
||||
@@ -57,7 +64,7 @@ export class P2POpenReplicationModal extends Modal {
|
||||
this.component = mount(P2POpenReplicationPane, {
|
||||
target: contentEl,
|
||||
props: {
|
||||
liveSyncReplicator: this.liveSyncReplicator,
|
||||
p2p: this.p2p,
|
||||
onSync: (peerId: string) => this.onSync(peerId),
|
||||
onSyncAndClose: (peerId: string) => this.onSyncAndClose(peerId),
|
||||
onClose: () => this.close(),
|
||||
|
||||
@@ -9,29 +9,28 @@
|
||||
// import type { TrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { delay, fireAndForget } from "octagonal-wheels/promises";
|
||||
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
interface Props {
|
||||
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||
onSync: (_peerId: string) => Promise<void>;
|
||||
onSyncAndClose: (_peerId: string) => Promise<void>;
|
||||
p2p: P2PServiceViews;
|
||||
onSync: (_peerId: string) => Promise<boolean>;
|
||||
onSyncAndClose: (_peerId: string) => Promise<boolean>;
|
||||
onClose: () => void;
|
||||
showResult: boolean;
|
||||
rebuildMode?: boolean;
|
||||
}
|
||||
|
||||
let { onSync, onSyncAndClose, onClose, showResult, liveSyncReplicator, rebuildMode = false }: Props = $props();
|
||||
const getLiveSyncReplicator = () => liveSyncReplicator;
|
||||
let { onSync, onSyncAndClose, onClose, showResult, p2p, rebuildMode = false }: Props = $props();
|
||||
|
||||
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
|
||||
let syncingPeerId = $state<string | null>(null);
|
||||
|
||||
const logLevel = $derived(showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
async function requestServerStatus() {
|
||||
await liveSyncReplicator.requestStatus();
|
||||
p2p.diagnostics.requestStatus();
|
||||
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||
}
|
||||
onMount(() => {
|
||||
@@ -50,8 +49,8 @@
|
||||
try {
|
||||
syncingPeerId = peerId;
|
||||
Logger(`Starting sync with ${peerId}`, logLevel);
|
||||
await onSync(peerId);
|
||||
Logger(`Sync completed with ${peerId}`, logLevel);
|
||||
const completed = await onSync(peerId);
|
||||
if (completed) Logger(`Sync completed with ${peerId}`, logLevel);
|
||||
} catch (e) {
|
||||
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||
} finally {
|
||||
@@ -62,8 +61,8 @@
|
||||
try {
|
||||
syncingPeerId = peerId;
|
||||
Logger(`Starting sync with ${peerId}`, logLevel);
|
||||
await onSyncAndClose(peerId);
|
||||
Logger(`Sync completed with ${peerId}`, logLevel);
|
||||
const completed = await onSyncAndClose(peerId);
|
||||
if (completed) Logger(`Sync completed with ${peerId}`, logLevel);
|
||||
} catch (e) {
|
||||
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||
} finally {
|
||||
@@ -73,7 +72,7 @@
|
||||
|
||||
async function disconnect() {
|
||||
try {
|
||||
await liveSyncReplicator.close();
|
||||
await p2p.transportLifecycle.disconnect();
|
||||
Logger("Signalling connection closed.", logLevel);
|
||||
} catch (e) {
|
||||
Logger(`Failed to close signalling connection: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||
@@ -100,7 +99,7 @@
|
||||
</script>
|
||||
|
||||
<div class="p2p-container">
|
||||
<P2PServerStatusCard {getLiveSyncReplicator} showBroadcastToggle={false} />
|
||||
<P2PServerStatusCard {p2p} showBroadcastToggle={false} />
|
||||
|
||||
<div class="peers-section">
|
||||
<h3>{translateMessage("Available Peers")}</h3>
|
||||
|
||||
@@ -2,21 +2,24 @@ import type { App } from "@/deps.ts";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { P2POpenReplicationModal } from "./P2POpenReplicationModal";
|
||||
|
||||
/**
|
||||
* Creates an openReplicationUI factory for Obsidian environments.
|
||||
* Returns a per-replicator closure that opens the P2P Replication modal
|
||||
* and performs bidirectional sync (pull then push on success).
|
||||
* Create the Obsidian-owned interactive P2P entry for stable service views.
|
||||
*
|
||||
* Peer selection belongs to the host UI rather than the concrete compatibility
|
||||
* Replicator. The returned operation opens the modal and performs bidirectional
|
||||
* synchronisation, pulling before pushing, through the targeted-transfer view.
|
||||
*
|
||||
* Usage:
|
||||
* const factory = createOpenReplicationUI(app);
|
||||
* useP2PReplicatorFeature(core, factory);
|
||||
* const createInteractiveReplication = createOpenReplicationUI(app);
|
||||
* const openInteractiveReplication = createInteractiveReplication(p2p);
|
||||
*/
|
||||
export function createOpenReplicationUI(
|
||||
app: App
|
||||
): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise<boolean | void> {
|
||||
return (replicator: LiveSyncTrysteroReplicator) =>
|
||||
): (p2p: P2PServiceViews) => (showResult: boolean) => Promise<boolean | void> {
|
||||
return (p2p: P2PServiceViews) =>
|
||||
(showResult: boolean): Promise<boolean | void> => {
|
||||
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
return new Promise<boolean | void>((resolve) => {
|
||||
@@ -36,20 +39,25 @@ export function createOpenReplicationUI(
|
||||
activeSynchronisations++;
|
||||
try {
|
||||
// Pull first, then push only when the pull succeeds.
|
||||
const pullResult = await replicator.replicateFrom(peerId, showResult);
|
||||
if (!pullResult?.ok) {
|
||||
const pullResult = await p2p.targetedTransfer.pullFromPeer(peerId, {
|
||||
showNotice: showResult,
|
||||
});
|
||||
if (pullResult.status !== "completed" || !pullResult.ok) {
|
||||
sessionResult = false;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const pushResult = await replicator.requestSynchroniseToPeer(peerId);
|
||||
sessionResult = pushResult?.ok ?? true;
|
||||
if (sessionResult && closeConnection) await replicator.close();
|
||||
const pushResult = await p2p.targetedTransfer.requestPushToPeer(peerId);
|
||||
const completed = pushResult.status === "completed" && pushResult.ok === true;
|
||||
sessionResult = completed;
|
||||
if (completed && closeConnection) await p2p.transportLifecycle.disconnect();
|
||||
return completed;
|
||||
} catch (e) {
|
||||
Logger(
|
||||
`Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
logLevel
|
||||
);
|
||||
sessionResult = false;
|
||||
return false;
|
||||
} finally {
|
||||
activeSynchronisations--;
|
||||
settleClosedSession();
|
||||
@@ -57,7 +65,7 @@ export function createOpenReplicationUI(
|
||||
};
|
||||
const modal = new P2POpenReplicationModal(
|
||||
app,
|
||||
replicator,
|
||||
p2p,
|
||||
{
|
||||
onSync: (peerId: string) => synchronise(peerId, false),
|
||||
onSyncAndClose: (peerId: string) => synchronise(peerId, true),
|
||||
@@ -81,12 +89,12 @@ export function createOpenReplicationUI(
|
||||
*
|
||||
* Usage:
|
||||
* const factory = createOpenRebuildUI(app);
|
||||
* useP2PReplicatorFeature(core, createOpenReplicationUI(app), factory);
|
||||
* useP2PReplicatorFeature(core, openReplicationUIFactory, factory);
|
||||
*/
|
||||
export function createOpenRebuildUI(
|
||||
app: App
|
||||
): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise<boolean | void> {
|
||||
return (replicator: LiveSyncTrysteroReplicator) =>
|
||||
): (replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) => (showResult: boolean) => Promise<boolean | void> {
|
||||
return (replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) =>
|
||||
(showResult: boolean): Promise<boolean | void> => {
|
||||
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
return new Promise<boolean | void>((resolve) => {
|
||||
@@ -113,12 +121,14 @@ export function createOpenRebuildUI(
|
||||
Logger(`Rebuilding from peer ${peerId}`, logLevel);
|
||||
const result = await replicator.replicateFrom(peerId, showResult, true);
|
||||
sessionResult = result?.ok ?? false;
|
||||
return sessionResult;
|
||||
} catch (e) {
|
||||
Logger(
|
||||
`Error in rebuild from ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
logLevel
|
||||
);
|
||||
sessionResult = false;
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
replicator.clearOnSetup();
|
||||
@@ -132,7 +142,7 @@ export function createOpenRebuildUI(
|
||||
|
||||
const modal = new P2POpenReplicationModal(
|
||||
app,
|
||||
replicator,
|
||||
p2p,
|
||||
{
|
||||
onSync: doRebuild,
|
||||
onSyncAndClose: doRebuild,
|
||||
|
||||
@@ -2,9 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const modalState = vi.hoisted(() => ({
|
||||
instances: [] as Array<{
|
||||
p2p: unknown;
|
||||
callback: {
|
||||
onSync: (peerId: string) => Promise<void>;
|
||||
onSyncAndClose: (peerId: string) => Promise<void>;
|
||||
onSync: (peerId: string) => Promise<boolean>;
|
||||
onSyncAndClose: (peerId: string) => Promise<boolean>;
|
||||
};
|
||||
onClosed?: () => void;
|
||||
open: ReturnType<typeof vi.fn>;
|
||||
@@ -15,18 +16,20 @@ vi.mock("@/deps.ts", () => ({ App: class {} }));
|
||||
|
||||
vi.mock("./P2POpenReplicationModal", () => ({
|
||||
P2POpenReplicationModal: class {
|
||||
p2p;
|
||||
callback;
|
||||
onClosed;
|
||||
open = vi.fn();
|
||||
|
||||
constructor(
|
||||
_app: unknown,
|
||||
_replicator: unknown,
|
||||
p2p: unknown,
|
||||
callback: (typeof modalState.instances)[number]["callback"],
|
||||
_showResult: boolean,
|
||||
_title?: string,
|
||||
onClosed?: () => void
|
||||
) {
|
||||
this.p2p = p2p;
|
||||
this.callback = callback;
|
||||
this.onClosed = onClosed;
|
||||
modalState.instances.push(this);
|
||||
@@ -38,23 +41,38 @@ import { createOpenRebuildUI, createOpenReplicationUI } from "./P2PReplicationUI
|
||||
|
||||
function createReplicator() {
|
||||
return {
|
||||
replicateFrom: vi.fn(async () => ({ ok: true })),
|
||||
requestSynchroniseToPeer: vi.fn(async () => ({ ok: true })),
|
||||
replicateFrom: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
|
||||
requestSynchroniseToPeer: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
|
||||
close: vi.fn(async () => undefined),
|
||||
setOnSetup: vi.fn(),
|
||||
clearOnSetup: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createP2PServiceViews() {
|
||||
return {
|
||||
transportLifecycle: {
|
||||
disconnect: vi.fn(async () => undefined),
|
||||
},
|
||||
targetedTransfer: {
|
||||
pullFromPeer: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
|
||||
requestPushToPeer: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
|
||||
},
|
||||
diagnostics: {},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("createOpenReplicationUI", () => {
|
||||
beforeEach(() => {
|
||||
modalState.instances.length = 0;
|
||||
});
|
||||
|
||||
it("settles a cancelled peer-selection session when the modal closes", async () => {
|
||||
const session = createOpenReplicationUI({} as any)(createReplicator())(true);
|
||||
const p2p = createP2PServiceViews();
|
||||
const session = createOpenReplicationUI({} as any)(p2p)(true);
|
||||
const modal = modalState.instances[0];
|
||||
|
||||
expect(modal.p2p).toBe(p2p);
|
||||
expect(modal.onClosed).toBeTypeOf("function");
|
||||
modal.onClosed?.();
|
||||
|
||||
@@ -62,36 +80,49 @@ describe("createOpenReplicationUI", () => {
|
||||
});
|
||||
|
||||
it("keeps repeated synchronisation inside the session boundary until the modal closes", async () => {
|
||||
const replicator = createReplicator();
|
||||
const session = createOpenReplicationUI({} as any)(replicator)(true);
|
||||
const p2p = createP2PServiceViews();
|
||||
const session = createOpenReplicationUI({} as any)(p2p)(true);
|
||||
const modal = modalState.instances[0];
|
||||
let settled = false;
|
||||
void session.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await modal.callback.onSync("peer-a");
|
||||
await expect(modal.callback.onSync("peer-a")).resolves.toBe(true);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(settled).toBe(false);
|
||||
await modal.callback.onSync("peer-b");
|
||||
expect(replicator.replicateFrom).toHaveBeenCalledTimes(2);
|
||||
expect(replicator.requestSynchroniseToPeer).toHaveBeenCalledTimes(2);
|
||||
expect(p2p.targetedTransfer.pullFromPeer).toHaveBeenCalledTimes(2);
|
||||
expect(p2p.targetedTransfer.requestPushToPeer).toHaveBeenCalledTimes(2);
|
||||
|
||||
modal.onClosed?.();
|
||||
await expect(session).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("routes ordinary peer transfer through the stable targeted-transfer view", async () => {
|
||||
const p2p = createP2PServiceViews();
|
||||
const session = createOpenReplicationUI({} as any)(p2p)(true);
|
||||
const modal = modalState.instances[0];
|
||||
|
||||
await modal.callback.onSync("peer-a");
|
||||
modal.onClosed?.();
|
||||
await expect(session).resolves.toBe(true);
|
||||
|
||||
expect(p2p.targetedTransfer.pullFromPeer).toHaveBeenCalledWith("peer-a", { showNotice: true });
|
||||
expect(p2p.targetedTransfer.requestPushToPeer).toHaveBeenCalledWith("peer-a");
|
||||
});
|
||||
|
||||
it("waits for an in-flight synchronisation when the modal closes", async () => {
|
||||
let finishPull!: (value: { ok: boolean }) => void;
|
||||
const replicator = createReplicator();
|
||||
replicator.replicateFrom.mockImplementation(
|
||||
let finishPull!: (value: { status: "completed"; ok: true }) => void;
|
||||
const p2p = createP2PServiceViews();
|
||||
p2p.targetedTransfer.pullFromPeer.mockImplementation(
|
||||
async () =>
|
||||
await new Promise<{ ok: boolean }>((resolve) => {
|
||||
await new Promise<{ status: "completed"; ok: true }>((resolve) => {
|
||||
finishPull = resolve;
|
||||
})
|
||||
);
|
||||
const session = createOpenReplicationUI({} as any)(replicator)(true);
|
||||
const session = createOpenReplicationUI({} as any)(p2p)(true);
|
||||
const modal = modalState.instances[0];
|
||||
let settled = false;
|
||||
void session.finally(() => {
|
||||
@@ -104,19 +135,19 @@ describe("createOpenReplicationUI", () => {
|
||||
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishPull({ ok: true });
|
||||
finishPull({ status: "completed", ok: true });
|
||||
await synchronisation;
|
||||
await expect(session).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("closes the P2P connection after a successful sync-and-close action", async () => {
|
||||
const replicator = createReplicator();
|
||||
const session = createOpenReplicationUI({} as any)(replicator)(true);
|
||||
const p2p = createP2PServiceViews();
|
||||
const session = createOpenReplicationUI({} as any)(p2p)(true);
|
||||
const modal = modalState.instances[0];
|
||||
|
||||
await modal.callback.onSyncAndClose("peer-a");
|
||||
|
||||
expect(replicator.close).toHaveBeenCalledOnce();
|
||||
expect(p2p.transportLifecycle.disconnect).toHaveBeenCalledOnce();
|
||||
let settled = false;
|
||||
void session.finally(() => {
|
||||
settled = true;
|
||||
@@ -127,6 +158,19 @@ describe("createOpenReplicationUI", () => {
|
||||
modal.onClosed?.();
|
||||
await expect(session).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns a cancelled peer push as non-success to the presentation boundary", async () => {
|
||||
const p2p = createP2PServiceViews();
|
||||
p2p.targetedTransfer.requestPushToPeer.mockResolvedValue({ status: "cancelled" } as never);
|
||||
const session = createOpenReplicationUI({} as any)(p2p)(true);
|
||||
const modal = modalState.instances[0];
|
||||
|
||||
const actionResult = await modal.callback.onSync("peer-a");
|
||||
modal.onClosed?.();
|
||||
|
||||
expect(actionResult).toBe(false);
|
||||
await expect(session).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOpenRebuildUI", () => {
|
||||
@@ -135,15 +179,15 @@ describe("createOpenRebuildUI", () => {
|
||||
});
|
||||
|
||||
it("waits for an in-flight rebuild when the modal closes", async () => {
|
||||
let finishPull!: (value: { ok: boolean }) => void;
|
||||
let finishPull!: (value: { status: "completed"; ok: true }) => void;
|
||||
const replicator = createReplicator();
|
||||
replicator.replicateFrom.mockImplementation(
|
||||
async () =>
|
||||
await new Promise<{ ok: boolean }>((resolve) => {
|
||||
await new Promise<{ status: "completed"; ok: true }>((resolve) => {
|
||||
finishPull = resolve;
|
||||
})
|
||||
);
|
||||
const session = createOpenRebuildUI({} as any)(replicator)(true);
|
||||
const session = createOpenRebuildUI({} as any)(replicator, createP2PServiceViews())(true);
|
||||
const modal = modalState.instances[0];
|
||||
let settled = false;
|
||||
void session.finally(() => {
|
||||
@@ -156,8 +200,8 @@ describe("createOpenRebuildUI", () => {
|
||||
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishPull({ ok: true });
|
||||
await rebuild;
|
||||
finishPull({ status: "completed", ok: true });
|
||||
await expect(rebuild).resolves.toBe(true);
|
||||
await expect(session).resolves.toBe(true);
|
||||
expect(replicator.setOnSetup).toHaveBeenCalledOnce();
|
||||
expect(replicator.replicateFrom).toHaveBeenCalledWith("peer-a", true, true);
|
||||
@@ -166,7 +210,7 @@ describe("createOpenRebuildUI", () => {
|
||||
|
||||
it("does not complete Fetch when the rebuild dialogue closes without selecting a peer", async () => {
|
||||
const replicator = createReplicator();
|
||||
const session = createOpenRebuildUI({} as any)(replicator)(true);
|
||||
const session = createOpenRebuildUI({} as any)(replicator, createP2PServiceViews())(true);
|
||||
const modal = modalState.instances[0];
|
||||
|
||||
modal.onClosed?.();
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
type PeerInfo,
|
||||
type P2PServerInfo,
|
||||
EVENT_SERVER_STATUS,
|
||||
EVENT_REQUEST_STATUS,
|
||||
EVENT_P2P_REPLICATOR_STATUS,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import type { P2PReplicatorStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
|
||||
@@ -29,7 +28,6 @@
|
||||
let services = $derived(host.services);
|
||||
let events = $derived(services.context.events);
|
||||
const currentSettings = () => services.setting.currentSettings() as P2PSyncSetting;
|
||||
const currentReplicator = () => host.p2p.replicator;
|
||||
const initialSettings = { ...currentSettings() } as P2PSyncSetting;
|
||||
|
||||
let settings = $state<P2PSyncSetting>(initialSettings);
|
||||
@@ -146,7 +144,7 @@
|
||||
replicatorInfo = status;
|
||||
});
|
||||
applyLoadSettings(currentSettings(), true);
|
||||
events.emitEvent(EVENT_REQUEST_STATUS);
|
||||
host.p2p.diagnostics.requestStatus();
|
||||
return () => {
|
||||
r();
|
||||
rx();
|
||||
@@ -223,16 +221,16 @@
|
||||
}
|
||||
|
||||
async function openServer() {
|
||||
await currentReplicator().open();
|
||||
await host.p2p.transportLifecycle.connect();
|
||||
}
|
||||
async function closeServer() {
|
||||
await currentReplicator().close();
|
||||
await host.p2p.transportLifecycle.disconnect();
|
||||
}
|
||||
function startBroadcasting() {
|
||||
currentReplicator().enableBroadcastChanges();
|
||||
host.p2p.changeRelay.enableBroadcastChanges();
|
||||
}
|
||||
function stopBroadcasting() {
|
||||
currentReplicator().disableBroadcastChanges();
|
||||
host.p2p.changeRelay.disableBroadcastChanges();
|
||||
}
|
||||
|
||||
const initialDialogStatusKey = `p2p-dialog-status`;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import type { RequiredServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
|
||||
export type P2PReplicatorHandle = Pick<UseP2PReplicatorResult, "replicator">;
|
||||
/**
|
||||
* The shared pane only needs the contracts which represent its visible
|
||||
* actions. In particular, it must not receive the compatibility Replicator
|
||||
* facade, whose lifecycle methods can bypass the stable P2P service owner.
|
||||
*/
|
||||
export type P2PReplicatorPaneP2P = Pick<
|
||||
P2PServiceViews,
|
||||
"transportLifecycle" | "peerDirectory" | "peerAdmission" | "targetedTransfer" | "changeRelay" | "diagnostics"
|
||||
>;
|
||||
|
||||
/** Host capabilities consumed by the shared P2P pane. */
|
||||
export interface P2PReplicatorPaneHost {
|
||||
readonly services: RequiredServices<"API" | "config" | "setting" | "vault">;
|
||||
readonly p2p: P2PReplicatorHandle;
|
||||
readonly p2p: P2PReplicatorPaneP2P;
|
||||
readonly showPeerMenu?: (peer: PeerStatus, event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { LOG_LEVEL_NOTICE, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import type { P2PPaneParams } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
export const VIEW_TYPE_P2P = "p2p-replicator";
|
||||
|
||||
function addToList(item: string, list: string) {
|
||||
@@ -31,7 +31,7 @@ function removeFromList(item: string, list: string) {
|
||||
|
||||
export class P2PReplicatorPaneView extends SvelteItemView {
|
||||
core: LiveSyncBaseCore;
|
||||
private _p2pResult: P2PPaneParams;
|
||||
private _p2p: P2PServiceViews;
|
||||
override icon = "waypoints";
|
||||
title: string = "";
|
||||
override navigation = false;
|
||||
@@ -39,21 +39,18 @@ export class P2PReplicatorPaneView extends SvelteItemView {
|
||||
override getIcon(): string {
|
||||
return "waypoints";
|
||||
}
|
||||
get replicator() {
|
||||
return this._p2pResult.replicator;
|
||||
}
|
||||
async replicateFrom(peer: PeerStatus) {
|
||||
await this.replicator.replicateFrom(peer.peerId);
|
||||
await this._p2p.targetedTransfer.pullFromPeer(peer.peerId);
|
||||
}
|
||||
async replicateTo(peer: PeerStatus) {
|
||||
await this.replicator.requestSynchroniseToPeer(peer.peerId);
|
||||
await this._p2p.targetedTransfer.requestPushToPeer(peer.peerId);
|
||||
}
|
||||
async getRemoteConfig(peer: PeerStatus) {
|
||||
Logger(
|
||||
`Requesting remote config for ${peer.name}. Please input the passphrase on the remote device`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
const remoteConfig = await this.replicator.getRemoteConfig(peer.peerId);
|
||||
const remoteConfig = await this._p2p.configurationExchange.getRemoteConfiguration(peer.peerId);
|
||||
if (remoteConfig) {
|
||||
Logger(`Remote config for ${peer.name} is retrieved successfully`);
|
||||
const DROP = "Yes, and drop local database";
|
||||
@@ -122,10 +119,10 @@ And you can also drop the local database to rebuild from the remote device.`,
|
||||
await this.core.services.setting.applyPartial(currentSetting, true);
|
||||
}
|
||||
m?: Menu;
|
||||
constructor(leaf: WorkspaceLeaf, core: LiveSyncBaseCore, p2pResult: P2PPaneParams) {
|
||||
constructor(leaf: WorkspaceLeaf, core: LiveSyncBaseCore, p2p: P2PServiceViews) {
|
||||
super(leaf);
|
||||
this.core = core;
|
||||
this._p2pResult = p2pResult;
|
||||
this._p2p = p2p;
|
||||
}
|
||||
|
||||
private showPeerMenu(peer: PeerStatus, event: MouseEvent): void {
|
||||
@@ -187,7 +184,7 @@ And you can also drop the local database to rebuild from the remote device.`,
|
||||
props: {
|
||||
host: {
|
||||
services: this.core.services,
|
||||
p2p: this._p2pResult,
|
||||
p2p: this._p2p,
|
||||
showPeerMenu: (peer: PeerStatus, event: MouseEvent) => this.showPeerMenu(peer, event),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,19 +9,19 @@
|
||||
EVENT_P2P_REPLICATOR_STATUS,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { P2PReplicatorStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
|
||||
import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
interface Props {
|
||||
getLiveSyncReplicator: () => LiveSyncTrysteroReplicator;
|
||||
p2p: P2PServiceViews;
|
||||
showBroadcastToggle?: boolean;
|
||||
core?: LiveSyncBaseCore;
|
||||
}
|
||||
|
||||
let { getLiveSyncReplicator, showBroadcastToggle = true, core }: Props = $props();
|
||||
let { p2p, showBroadcastToggle = true, core }: Props = $props();
|
||||
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
|
||||
let replicatorStatus = $state<P2PReplicatorStatus | undefined>(undefined);
|
||||
// Later setting changes arrive through EVENT_SETTING_SAVED; these values only seed local state at mount time.
|
||||
@@ -31,25 +31,25 @@
|
||||
let useDiagRTC = $state<boolean>(initialSettings?.P2P_useDiagRTC ?? false);
|
||||
|
||||
async function requestServerStatus() {
|
||||
await Promise.resolve(getLiveSyncReplicator().requestStatus());
|
||||
p2p.diagnostics.requestStatus();
|
||||
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||
}
|
||||
|
||||
async function onOpenConnection() {
|
||||
await getLiveSyncReplicator().makeSureOpened();
|
||||
await p2p.transportLifecycle.connect();
|
||||
await requestServerStatus();
|
||||
}
|
||||
|
||||
async function onDisconnect() {
|
||||
await getLiveSyncReplicator().close();
|
||||
await p2p.transportLifecycle.disconnect();
|
||||
await requestServerStatus();
|
||||
}
|
||||
|
||||
function toggleBroadcast() {
|
||||
if (replicatorStatus?.isBroadcasting) {
|
||||
getLiveSyncReplicator().disableBroadcastChanges();
|
||||
p2p.changeRelay.disableBroadcastChanges();
|
||||
} else {
|
||||
getLiveSyncReplicator().enableBroadcastChanges();
|
||||
p2p.changeRelay.enableBroadcastChanges();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
EVENT_P2P_REPLICATOR_PROGRESS,
|
||||
type P2PServerInfo,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { P2PReplicatorStatus, P2PReplicationReport } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
|
||||
import { delay, fireAndForget } from "octagonal-wheels/promises";
|
||||
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
|
||||
@@ -23,7 +23,6 @@
|
||||
} from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { SetupManager } from "@/modules/features/SetupManager";
|
||||
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
import { Menu } from "@/deps";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import {
|
||||
@@ -33,11 +32,11 @@
|
||||
} from "./p2pPeerSettings";
|
||||
|
||||
interface Props {
|
||||
getLiveSyncReplicator: () => LiveSyncTrysteroReplicator;
|
||||
p2p: P2PServiceViews;
|
||||
core: LiveSyncBaseCore;
|
||||
}
|
||||
|
||||
let { getLiveSyncReplicator, core }: Props = $props();
|
||||
let { p2p, core }: Props = $props();
|
||||
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
|
||||
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
|
||||
let decidingPeerId = $state<string | null>(null);
|
||||
@@ -121,7 +120,7 @@
|
||||
}
|
||||
|
||||
async function requestServerStatus() {
|
||||
await getLiveSyncReplicator().requestStatus();
|
||||
p2p.diagnostics.requestStatus();
|
||||
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||
}
|
||||
|
||||
@@ -213,9 +212,8 @@
|
||||
|
||||
async function createAndSelectP2PRemote() {
|
||||
const setupManager = core.getModule(SetupManager);
|
||||
const dialogManager = setupManager.dialogManager;
|
||||
const currentSettings = core.services.setting.currentSettings();
|
||||
const p2pConf = await dialogManager.openWithExplicitCancel(SetupRemoteP2P, currentSettings);
|
||||
const p2pConf = await setupManager.openP2PSetup(currentSettings);
|
||||
if (p2pConf === "cancelled" || typeof p2pConf !== "object" || !p2pConf) {
|
||||
return;
|
||||
}
|
||||
@@ -296,7 +294,7 @@
|
||||
) {
|
||||
decidingPeerId = peer.peerId;
|
||||
try {
|
||||
await getLiveSyncReplicator().makeDecision({
|
||||
await p2p.peerAdmission.makeDecision({
|
||||
peerId: peer.peerId,
|
||||
name: peer.name,
|
||||
decision,
|
||||
@@ -311,7 +309,7 @@
|
||||
async function revokeDecision(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
decidingPeerId = peer.peerId;
|
||||
try {
|
||||
await getLiveSyncReplicator().revokeDecision({
|
||||
await p2p.peerAdmission.revokeDecision({
|
||||
peerId: peer.peerId,
|
||||
name: peer.name,
|
||||
});
|
||||
@@ -324,10 +322,7 @@
|
||||
async function startReplication(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
replicatingPeerId = peer.peerId;
|
||||
try {
|
||||
const pullResult = await getLiveSyncReplicator().replicateFrom(peer.peerId, true);
|
||||
if (pullResult?.ok) {
|
||||
await getLiveSyncReplicator().requestSynchroniseToPeer(peer.peerId);
|
||||
}
|
||||
await p2p.targetedTransfer.synchroniseWithPeer(peer.peerId, true);
|
||||
await requestServerStatus();
|
||||
} finally {
|
||||
replicatingPeerId = null;
|
||||
@@ -347,9 +342,9 @@
|
||||
return;
|
||||
}
|
||||
if (isWatching(peerId)) {
|
||||
getLiveSyncReplicator().unwatchPeer(peerId);
|
||||
p2p.changeRelay.unwatchPeer(peerId);
|
||||
} else {
|
||||
getLiveSyncReplicator().watchPeer(peerId);
|
||||
p2p.changeRelay.watchPeer(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,7 +450,7 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<P2PServerStatusCard {getLiveSyncReplicator} {core} />
|
||||
<P2PServerStatusCard {p2p} {core} />
|
||||
|
||||
<div class="peers-section">
|
||||
<div class="peers-header">
|
||||
|
||||
@@ -2,21 +2,21 @@ import { WorkspaceLeaf } from "@/deps.ts";
|
||||
import { mount } from "svelte";
|
||||
import { SvelteItemView } from "@/common/SvelteItemView.ts";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import type { P2PPaneParams } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import P2PServerStatusPane from "./P2PServerStatusPane.svelte";
|
||||
|
||||
export const VIEW_TYPE_P2P_SERVER_STATUS = "p2p-server-status";
|
||||
|
||||
export class P2PServerStatusPaneView extends SvelteItemView {
|
||||
core: LiveSyncBaseCore;
|
||||
private _p2pResult: P2PPaneParams;
|
||||
private readonly p2p: P2PServiceViews;
|
||||
override icon = "waypoints";
|
||||
override navigation = false;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, core: LiveSyncBaseCore, p2pResult: P2PPaneParams) {
|
||||
constructor(leaf: WorkspaceLeaf, core: LiveSyncBaseCore, p2p: P2PServiceViews) {
|
||||
super(leaf);
|
||||
this.core = core;
|
||||
this._p2pResult = p2pResult;
|
||||
this.p2p = p2p;
|
||||
}
|
||||
|
||||
override getIcon(): string {
|
||||
@@ -35,7 +35,7 @@ export class P2PServerStatusPaneView extends SvelteItemView {
|
||||
return mount(P2PServerStatusPane, {
|
||||
target,
|
||||
props: {
|
||||
getLiveSyncReplicator: () => this._p2pResult.replicator,
|
||||
p2p: this.p2p,
|
||||
core: this.core,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { AcceptedStatus, type PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import type { P2PReplicatorHandle } from "./P2PReplicatorPaneHost";
|
||||
import type { P2PReplicatorPaneP2P } from "./P2PReplicatorPaneHost";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
interface Props {
|
||||
peerStatus: PeerStatus;
|
||||
p2p: P2PReplicatorHandle;
|
||||
p2p: P2PReplicatorPaneP2P;
|
||||
showPeerMenu?: (peer: PeerStatus, event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let { peerStatus, p2p, showPeerMenu }: Props = $props();
|
||||
let peer = $derived(peerStatus);
|
||||
const currentReplicator = () => p2p.replicator;
|
||||
|
||||
function select<T extends PropertyKey, U, V = undefined>(
|
||||
d: T,
|
||||
@@ -72,7 +71,7 @@
|
||||
let isNew = $derived.by(() => peer.accepted === AcceptedStatus.UNKNOWN);
|
||||
|
||||
function makeDecision(isAccepted: boolean, isTemporary: boolean) {
|
||||
currentReplicator().makeDecision({
|
||||
void p2p.peerAdmission.makeDecision({
|
||||
peerId: peer.peerId,
|
||||
name: peer.name,
|
||||
decision: isAccepted,
|
||||
@@ -80,7 +79,7 @@
|
||||
});
|
||||
}
|
||||
function revokeDecision() {
|
||||
currentReplicator().revokeDecision({
|
||||
void p2p.peerAdmission.revokeDecision({
|
||||
peerId: peer.peerId,
|
||||
name: peer.name,
|
||||
});
|
||||
@@ -99,14 +98,14 @@
|
||||
return attrs;
|
||||
});
|
||||
function startWatching() {
|
||||
currentReplicator().watchPeer(peer.peerId);
|
||||
p2p.changeRelay.watchPeer(peer.peerId);
|
||||
}
|
||||
function stopWatching() {
|
||||
currentReplicator().unwatchPeer(peer.peerId);
|
||||
p2p.changeRelay.unwatchPeer(peer.peerId);
|
||||
}
|
||||
|
||||
function sync() {
|
||||
void currentReplicator().sync(peer.peerId, false);
|
||||
void p2p.targetedTransfer.synchroniseWithPeer(peer.peerId, false);
|
||||
}
|
||||
|
||||
function moreMenu(evt: MouseEvent) {
|
||||
|
||||
@@ -24,14 +24,6 @@ export const REVIEW_HARNESS_SCENARIOS = [
|
||||
mode: "guided",
|
||||
access: "device-local-state",
|
||||
},
|
||||
{
|
||||
id: "p2p-composition",
|
||||
title: "P2P composition",
|
||||
description:
|
||||
"Checks that the Obsidian host and P2P interface still resolve the current Commonlib replicator.",
|
||||
mode: "automatic",
|
||||
access: "read-only",
|
||||
},
|
||||
{
|
||||
id: "vault-round-trip",
|
||||
title: "Vault fixture round trip",
|
||||
|
||||
@@ -74,7 +74,6 @@ describe("Review Harness contract", () => {
|
||||
expect(REVIEW_HARNESS_SCENARIO_IDS).toEqual([
|
||||
"settings-lifecycle",
|
||||
"compatibility-review",
|
||||
"p2p-composition",
|
||||
"vault-round-trip",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -20,11 +20,6 @@ export interface ReviewHarnessRuntime {
|
||||
isCompatibilityReviewInitialised(): boolean;
|
||||
getCompatibilityPause(): CompatibilityPause | undefined;
|
||||
openCompatibilityReview(): Promise<void>;
|
||||
getP2PComposition(): {
|
||||
readonly first: unknown;
|
||||
readonly second: unknown;
|
||||
readonly expectedServices: unknown;
|
||||
};
|
||||
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
|
||||
readContinuation(): string | null;
|
||||
writeContinuation(value: string): void;
|
||||
@@ -63,37 +58,6 @@ function initialResults(): Record<ReviewHarnessScenarioId, ReviewHarnessScenario
|
||||
>;
|
||||
}
|
||||
|
||||
function inspectP2PComposition(input: ReturnType<ReviewHarnessRuntime["getP2PComposition"]>): ReviewHarnessScenarioResult {
|
||||
if (input.first !== input.second) {
|
||||
return {
|
||||
status: "failed",
|
||||
detail: "Two consecutive reads resolved different P2P replicators without a lifecycle transition.",
|
||||
observations: [],
|
||||
};
|
||||
}
|
||||
if (typeof input.first !== "object" || input.first === null) {
|
||||
return {
|
||||
status: "failed",
|
||||
detail: "The P2P composition did not expose a current replicator.",
|
||||
observations: [],
|
||||
};
|
||||
}
|
||||
const env = "env" in input.first ? input.first.env : undefined;
|
||||
const services = typeof env === "object" && env !== null && "services" in env ? env.services : undefined;
|
||||
if (services !== input.expectedServices) {
|
||||
return {
|
||||
status: "failed",
|
||||
detail: "The current P2P replicator is not bound to the active Obsidian services.",
|
||||
observations: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "passed",
|
||||
detail: "The live P2P result resolves the current replicator and active Obsidian services.",
|
||||
observations: [],
|
||||
};
|
||||
}
|
||||
|
||||
export class ReviewHarnessController {
|
||||
private readonly results = initialResults();
|
||||
private readonly transcript: ReviewHarnessTranscriptEntry[] = [];
|
||||
@@ -163,9 +127,7 @@ export class ReviewHarnessController {
|
||||
}
|
||||
|
||||
async runAutomaticScenarios(): Promise<void> {
|
||||
for (const id of ["settings-lifecycle", "p2p-composition"] as const) {
|
||||
await this.runScenario(id);
|
||||
}
|
||||
await this.runScenario("settings-lifecycle");
|
||||
}
|
||||
|
||||
async runAllScenarios(): Promise<void> {
|
||||
@@ -195,8 +157,6 @@ export class ReviewHarnessController {
|
||||
settings: this.runtime.getSettings(),
|
||||
newVaultSettings: this.runtime.getNewVaultSettings(),
|
||||
});
|
||||
} else if (id === "p2p-composition") {
|
||||
result = inspectP2PComposition(this.runtime.getP2PComposition());
|
||||
} else if (id === "vault-round-trip") {
|
||||
result = await this.runtime.runVaultRoundTrip();
|
||||
} else {
|
||||
|
||||
@@ -43,8 +43,6 @@ function createRuntime(): ReviewHarnessRuntime & {
|
||||
continuation: string | null;
|
||||
events: string[];
|
||||
} {
|
||||
const services = {};
|
||||
const replicator = { env: { services } };
|
||||
const runtime: ReviewHarnessRuntime & {
|
||||
compatibilityReviewInitialised: boolean;
|
||||
compatibilityPause: CompatibilityPause | undefined;
|
||||
@@ -77,7 +75,6 @@ function createRuntime(): ReviewHarnessRuntime & {
|
||||
runtime.events.push("open-compatibility-review");
|
||||
runtime.compatibilityPause = undefined;
|
||||
}),
|
||||
getP2PComposition: () => ({ first: replicator, second: replicator, expectedServices: services }),
|
||||
runVaultRoundTrip: vi.fn(async () => ({
|
||||
status: "passed" as const,
|
||||
detail: "The owned fixture tree was exercised and removed.",
|
||||
@@ -110,14 +107,13 @@ function createRuntime(): ReviewHarnessRuntime & {
|
||||
}
|
||||
|
||||
describe("ReviewHarnessController", () => {
|
||||
it("runs the automatic settings and P2P composition checks", async () => {
|
||||
it("runs the automatic settings check", async () => {
|
||||
const runtime = createRuntime();
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
await controller.runAutomaticScenarios();
|
||||
|
||||
expect(controller.snapshot().results["settings-lifecycle"].status).toBe("passed");
|
||||
expect(controller.snapshot().results["p2p-composition"].status).toBe("passed");
|
||||
expect(controller.snapshot().results["vault-round-trip"].status).toBe("idle");
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
|
||||
});
|
||||
|
||||
+6
-9
@@ -39,8 +39,7 @@ import { useSetupProtocolFeature } from "./serviceFeatures/setupObsidian/setupPr
|
||||
import { useSetupQRCodeFeature } from "@/serviceFeatures/setupObsidian/qrCode";
|
||||
import { useSetupURIFeature } from "@/serviceFeatures/setupObsidian/setupUri";
|
||||
import { useSetupManagerHandlersFeature } from "./serviceFeatures/setupObsidian/setupManagerHandlers.ts";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import { useP2PReplicatorCommands } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorCommands";
|
||||
import { useP2PReplicatorCommands, useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts";
|
||||
import { useReviewHarness } from "./serviceFeatures/useReviewHarness.ts";
|
||||
import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync/P2PReplicator/P2PReplicationUI.ts";
|
||||
@@ -179,13 +178,15 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
const curriedFeature = () => featuresInitialiser(core);
|
||||
core.services.appLifecycle.onLayoutReady.addHandler(curriedFeature);
|
||||
const setupManager = core.getModule(SetupManager);
|
||||
const createInteractiveP2PReplication = createOpenReplicationUI(this.app);
|
||||
const replicator = useP2PReplicatorFeature(
|
||||
core,
|
||||
createOpenReplicationUI(this.app),
|
||||
(_compatibilityReplicator, p2p) => createInteractiveP2PReplication(p2p),
|
||||
createOpenRebuildUI(this.app)
|
||||
);
|
||||
setupManager.registerP2PSetupConnectionProbe(replicator.connectionProbe);
|
||||
useP2PReplicatorCommands(core, replicator);
|
||||
useP2PReplicatorUI(core, core, replicator);
|
||||
useP2PReplicatorUI(core, core, replicator, createInteractiveP2PReplication(replicator));
|
||||
useRemoteConfiguration(core);
|
||||
|
||||
useSetupProtocolFeature(core, setupManager);
|
||||
@@ -200,11 +201,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
createObsidianCompatibilityReviewUi(core.confirm)
|
||||
);
|
||||
waitForCompatibilityReview = () => compatibilityReview.openReview();
|
||||
useReviewHarness(core, this, replicator, compatibilityReview);
|
||||
// p2pReplicatorResult = useP2PReplicator(core, [
|
||||
// VIEW_TYPE_P2P,
|
||||
// (leaf: any) => new P2PReplicatorPaneView(leaf, core, p2pReplicatorResult!),
|
||||
// ]);
|
||||
useReviewHarness(core, this, compatibilityReview);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { PeriodicProcessor } from "@/common/PeriodicProcessor";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
|
||||
export class ModulePeriodicProcess extends AbstractModule {
|
||||
periodicSyncProcessor = new PeriodicProcessor(this.core, async () => await this.services.replication.replicate());
|
||||
|
||||
disablePeriodic() {
|
||||
this.periodicSyncProcessor?.disable();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
resumePeriodic() {
|
||||
this.periodicSyncProcessor.enable(
|
||||
this.settings.periodicReplication ? this.settings.periodicReplicationInterval * 1000 : 0
|
||||
);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
private _allOnUnload() {
|
||||
return this.disablePeriodic();
|
||||
}
|
||||
private _everyBeforeRealizeSetting(): Promise<boolean> {
|
||||
return this.disablePeriodic();
|
||||
}
|
||||
private _everyBeforeSuspendProcess(): Promise<boolean> {
|
||||
return this.disablePeriodic();
|
||||
}
|
||||
private _everyAfterResumeProcess(): Promise<boolean> {
|
||||
return this.resumePeriodic();
|
||||
}
|
||||
private _everyAfterRealizeSetting(): Promise<boolean> {
|
||||
return this.resumePeriodic();
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onUnload.addHandler(this._allOnUnload.bind(this));
|
||||
services.setting.onBeforeRealiseSetting.addHandler(this._everyBeforeRealizeSetting.bind(this));
|
||||
services.setting.onSettingRealised.addHandler(this._everyAfterRealizeSetting.bind(this));
|
||||
services.appLifecycle.onSuspending.addHandler(this._everyBeforeSuspendProcess.bind(this));
|
||||
services.appLifecycle.onResumed.addHandler(this._everyAfterResumeProcess.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
import type PouchDB from "pouchdb-core";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
|
||||
import { skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||
import { balanceChunkPurgedDBs } from "@vrtmrz/livesync-commonlib/compat/pouchdb/chunks";
|
||||
import { purgeUnreferencedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/chunks";
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import {
|
||||
type EntryDoc,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type RemoteType,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
import { EVENT_FILE_SAVED, EVENT_SETTING_SAVED, eventHub } from "@/common/events";
|
||||
|
||||
import { $msg } from "@/common/translation";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
|
||||
import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager";
|
||||
import { clearHandlers } from "@vrtmrz/livesync-commonlib/compat/replication/SyncParamsHandler";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { MARK_LOG_NETWORK_ERROR } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { usesLegacyIndexedDBAdapter } from "@/common/compatibilitySettings.ts";
|
||||
|
||||
function isOnlineAndCanReplicate(
|
||||
errorManager: UnresolvedErrorManager,
|
||||
host: NecessaryServices<"API", never>,
|
||||
showMessage: boolean
|
||||
): Promise<boolean> {
|
||||
const errorMessage = "Network is offline";
|
||||
if (!host.services.API.isOnline) {
|
||||
errorManager.showError(errorMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
errorManager.clearError(errorMessage);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async function canReplicateWithPBKDF2(
|
||||
errorManager: UnresolvedErrorManager,
|
||||
host: NecessaryServices<"replicator" | "setting", never>,
|
||||
showMessage: boolean
|
||||
): Promise<boolean> {
|
||||
const currentSettings = host.services.setting.currentSettings();
|
||||
// TODO: check using PBKDF2 salt?
|
||||
const errorMessage = $msg("Replicator.Message.InitialiseFatalError");
|
||||
const replicator = host.services.replicator.getActiveReplicator();
|
||||
if (!replicator) {
|
||||
errorManager.showError(errorMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
errorManager.clearError(errorMessage);
|
||||
// Showing message is false: that because be shown here. (And it is a fatal error, no way to hide it).
|
||||
// tagged as network error at beginning for error filtering with NetworkWarningStyles
|
||||
const ensureMessage = `${MARK_LOG_NETWORK_ERROR}Failed to initialise the encryption key, preventing replication.`;
|
||||
// A remote database rebuild replaces the Security Seed while this process may still hold the previous one.
|
||||
const ensureResult = await replicator.ensurePBKDF2Salt(currentSettings, showMessage, false);
|
||||
if (!ensureResult) {
|
||||
errorManager.showError(ensureMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
errorManager.clearError(ensureMessage);
|
||||
return ensureResult; // is true.
|
||||
}
|
||||
|
||||
export class ModuleReplicator extends AbstractModule {
|
||||
_replicatorType?: RemoteType;
|
||||
|
||||
processor: ReplicateResultProcessor = new ReplicateResultProcessor(this);
|
||||
private _unresolvedErrorManager: UnresolvedErrorManager = new UnresolvedErrorManager(
|
||||
this.core.services.appLifecycle,
|
||||
this.core.services.context.events
|
||||
);
|
||||
|
||||
clearErrors() {
|
||||
this._unresolvedErrorManager.clearErrors();
|
||||
}
|
||||
|
||||
private _normalFileReflectionFilterSignature: string | undefined;
|
||||
|
||||
private getNormalFileReflectionFilterSignature(
|
||||
settings: Pick<
|
||||
ObsidianLiveSyncSettings,
|
||||
| "handleFilenameCaseSensitive"
|
||||
| "ignoreFiles"
|
||||
| "maxMTimeForReflectEvents"
|
||||
| "syncIgnoreRegEx"
|
||||
| "syncInternalFiles"
|
||||
| "syncMaxSizeInMB"
|
||||
| "syncOnlyRegEx"
|
||||
| "useIgnoreFiles"
|
||||
>
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
handleFilenameCaseSensitive: settings.handleFilenameCaseSensitive ?? false,
|
||||
ignoreFiles: settings.ignoreFiles ?? "",
|
||||
maxMTimeForReflectEvents: settings.maxMTimeForReflectEvents ?? 0,
|
||||
syncIgnoreRegEx: settings.syncIgnoreRegEx ?? "",
|
||||
syncInternalFiles: settings.syncInternalFiles ?? false,
|
||||
syncMaxSizeInMB: settings.syncMaxSizeInMB ?? 0,
|
||||
syncOnlyRegEx: settings.syncOnlyRegEx ?? "",
|
||||
useIgnoreFiles: settings.useIgnoreFiles ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
private _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
this._normalFileReflectionFilterSignature = this.getNormalFileReflectionFilterSignature(this.settings);
|
||||
eventHub.onEvent(EVENT_FILE_SAVED, () => {
|
||||
if (this.settings.syncOnSave && !this.core.services.appLifecycle.isSuspended()) {
|
||||
scheduleTask("perform-replicate-after-save", 250, () => this.services.replication.replicateByEvent());
|
||||
}
|
||||
});
|
||||
eventHub.onEvent(EVENT_SETTING_SAVED, (setting) => {
|
||||
const previousReflectionFilter = this._normalFileReflectionFilterSignature;
|
||||
const nextReflectionFilter = this.getNormalFileReflectionFilterSignature(setting);
|
||||
this._normalFileReflectionFilterSignature = nextReflectionFilter;
|
||||
if (this.core.settings.suspendParseReplicationResult) {
|
||||
this.processor.suspend();
|
||||
} else {
|
||||
this.processor.resume();
|
||||
}
|
||||
if (previousReflectionFilter !== undefined && previousReflectionFilter !== nextReflectionFilter) {
|
||||
fireAndForget(() => this.processor.reprocessStoredDocuments());
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
_onReplicatorInitialised(): Promise<boolean> {
|
||||
// For now, we only need to clear the error related to replicator initialisation, but in the future, if there are more things to do when the replicator is initialised, we can add them here.
|
||||
clearHandlers();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
_everyOnDatabaseInitialized(showNotice: boolean): Promise<boolean> {
|
||||
fireAndForget(() => this.processor.restoreFromSnapshotOnce());
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async _everyBeforeReplicate(showMessage: boolean): Promise<boolean> {
|
||||
await this.processor.restoreFromSnapshotOnce();
|
||||
this.clearErrors();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles an IndexedDB-backed local database after replication reports that the remote was cleaned.
|
||||
*
|
||||
* The remote milestone remains a supported compatibility signal. The user can either fetch the remote
|
||||
* database again, or purge unreferenced local chunks before accepting this device again.
|
||||
*
|
||||
* @param showMessage Whether to show the recovery choices as user-facing notices.
|
||||
*/
|
||||
async cleaned(showMessage: boolean) {
|
||||
Logger(`The remote database has been cleaned.`, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
await skipIfDuplicated("cleanup", async () => {
|
||||
const count = await purgeUnreferencedChunks(this.localDatabase.localDatabase, true);
|
||||
const message = `The remote database has been cleaned up.
|
||||
To synchronize, this device must be also cleaned up. ${count} chunk(s) will be erased from this device.
|
||||
However, If there are many chunks to be deleted, maybe fetching again is faster.
|
||||
We will lose the history of this device if we fetch the remote database again.
|
||||
Even if you choose to clean up, you will see this option again if you exit Obsidian and then synchronise again.`;
|
||||
const CHOICE_FETCH = "Fetch again";
|
||||
const CHOICE_CLEAN = "Cleanup";
|
||||
const CHOICE_DISMISS = "Dismiss";
|
||||
const ret = await this.core.confirm.confirmWithMessage(
|
||||
"Cleaned",
|
||||
message,
|
||||
[CHOICE_FETCH, CHOICE_CLEAN, CHOICE_DISMISS],
|
||||
CHOICE_DISMISS,
|
||||
30
|
||||
);
|
||||
if (ret == CHOICE_FETCH) {
|
||||
await this.core.rebuilder.$performRebuildDB("localOnly");
|
||||
}
|
||||
if (ret == CHOICE_CLEAN) {
|
||||
await this.services.replicator.runBoundedRemoteActivity(
|
||||
async () => {
|
||||
const replicator = this.services.replicator.getActiveReplicator();
|
||||
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return;
|
||||
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(
|
||||
this.settings,
|
||||
this.services.API.isMobile(),
|
||||
true
|
||||
);
|
||||
if (typeof remoteDB == "string") {
|
||||
Logger(remoteDB, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||
this.localDatabase.clearCaches();
|
||||
// Perform the synchronisation once.
|
||||
const replicated = await this.services.replicator.runFiniteReplicationActivity(
|
||||
() => this.core.replicator.openReplication(this.settings, false, showMessage, true),
|
||||
{ label: "replication" }
|
||||
);
|
||||
if (replicated) {
|
||||
await balanceChunkPurgedDBs(this.localDatabase.localDatabase, remoteDB.db);
|
||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||
this.localDatabase.clearCaches();
|
||||
await this.services.replicator.getActiveReplicator()?.markRemoteResolved(this.settings);
|
||||
Logger(
|
||||
"The local database has been cleaned up.",
|
||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
} else {
|
||||
Logger(
|
||||
"Replication has been cancelled. Please try it again.",
|
||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await remoteDB.db.close();
|
||||
}
|
||||
},
|
||||
{ label: "database-cleanup" }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async onReplicationFailed(showMessage: boolean = false): Promise<boolean> {
|
||||
const activeReplicator = this.services.replicator.getActiveReplicator();
|
||||
if (!activeReplicator) {
|
||||
Logger(`No active replicator found`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
if (activeReplicator.tweakSettingsMismatched && activeReplicator.preferredTweakValue) {
|
||||
await this.services.tweakValue.askResolvingMismatched(activeReplicator.preferredTweakValue);
|
||||
} else {
|
||||
if (activeReplicator.remoteLockedAndDeviceNotAccepted) {
|
||||
if (activeReplicator.remoteCleaned && usesLegacyIndexedDBAdapter(this.settings)) {
|
||||
await this.cleaned(showMessage);
|
||||
} else {
|
||||
const message = $msg("Replicator.Dialogue.Locked.Message");
|
||||
const CHOICE_FETCH = $msg("Replicator.Dialogue.Locked.Action.Fetch");
|
||||
const CHOICE_DISMISS = $msg("Replicator.Dialogue.Locked.Action.Dismiss");
|
||||
const CHOICE_UNLOCK = $msg("Replicator.Dialogue.Locked.Action.Unlock");
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
||||
message,
|
||||
[CHOICE_FETCH, CHOICE_UNLOCK, CHOICE_DISMISS],
|
||||
{
|
||||
title: $msg("Replicator.Dialogue.Locked.Title"),
|
||||
defaultAction: CHOICE_DISMISS,
|
||||
timeout: 60,
|
||||
}
|
||||
);
|
||||
if (ret == CHOICE_FETCH) {
|
||||
this._log($msg("Replicator.Dialogue.Locked.Message.Fetch"), LOG_LEVEL_NOTICE);
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
this.services.appLifecycle.scheduleRestart();
|
||||
return false;
|
||||
} else if (ret == CHOICE_UNLOCK) {
|
||||
await activeReplicator.markRemoteResolved(this.settings);
|
||||
this._log($msg("Replicator.Dialogue.Locked.Message.Unlocked"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: Check again and true/false return. This will be the result for performReplication.
|
||||
return false;
|
||||
}
|
||||
|
||||
// private async _replicateByEvent(): Promise<boolean | void> {
|
||||
// const least = this.settings.syncMinimumInterval;
|
||||
// if (least > 0) {
|
||||
// return rateLimitedSharedExecution(KEY_REPLICATION_ON_EVENT, least, async () => {
|
||||
// return await this.services.replication.replicate();
|
||||
// });
|
||||
// }
|
||||
// return await shareRunningResult(`replication`, () => this.services.replication.replicate());
|
||||
// }
|
||||
|
||||
_parseReplicationResult(docs: Array<PouchDB.Core.ExistingDocument<EntryDoc>>): Promise<boolean> {
|
||||
this.processor.enqueueAll(docs);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
// _everyBeforeSuspendProcess(): Promise<boolean> {
|
||||
// this.core.replicator?.closeReplication();
|
||||
// return Promise.resolve(true);
|
||||
// }
|
||||
|
||||
// private async _replicateAllToServer(
|
||||
// showingNotice: boolean = false,
|
||||
// sendChunksInBulkDisabled: boolean = false
|
||||
// ): Promise<boolean> {
|
||||
// if (!this.services.appLifecycle.isReady()) return false;
|
||||
// if (!(await this.services.replication.onBeforeReplicate(showingNotice))) {
|
||||
// Logger($msg("Replicator.Message.SomeModuleFailed"), LOG_LEVEL_NOTICE);
|
||||
// return false;
|
||||
// }
|
||||
// if (!sendChunksInBulkDisabled) {
|
||||
// if (this.core.replicator instanceof LiveSyncCouchDBReplicator) {
|
||||
// if (
|
||||
// (await this.core.confirm.askYesNoDialog("Do you want to send all chunks before replication?", {
|
||||
// defaultOption: "No",
|
||||
// timeout: 20,
|
||||
// })) == "yes"
|
||||
// ) {
|
||||
// await this.core.replicator.sendChunks(this.core.settings, undefined, true, 0);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// const ret = await this.core.replicator.replicateAllToServer(this.settings, showingNotice);
|
||||
// if (ret) return true;
|
||||
// const checkResult = await this.services.replication.checkConnectionFailure();
|
||||
// if (checkResult == "CHECKAGAIN") return await this.services.remote.replicateAllToRemote(showingNotice);
|
||||
// return !checkResult;
|
||||
// }
|
||||
// async _replicateAllFromServer(showingNotice: boolean = false): Promise<boolean> {
|
||||
// if (!this.services.appLifecycle.isReady()) return false;
|
||||
// const ret = await this.core.replicator.replicateAllFromServer(this.settings, showingNotice);
|
||||
// if (ret) return true;
|
||||
// const checkResult = await this.services.replication.checkConnectionFailure();
|
||||
// if (checkResult == "CHECKAGAIN") return await this.services.remote.replicateAllFromRemote(showingNotice);
|
||||
// return !checkResult;
|
||||
// }
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.replicator.onReplicatorInitialised.addHandler(this._onReplicatorInitialised.bind(this));
|
||||
services.databaseEvents.onDatabaseInitialised.addHandler(this._everyOnDatabaseInitialized.bind(this));
|
||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
||||
services.replication.parseSynchroniseResult.addHandler(this._parseReplicationResult.bind(this));
|
||||
|
||||
// --> These handlers can be separated.
|
||||
const isOnlineAndCanReplicateWithHost = isOnlineAndCanReplicate.bind(null, this._unresolvedErrorManager, {
|
||||
services: {
|
||||
context: services.context,
|
||||
API: services.API,
|
||||
},
|
||||
serviceModules: {},
|
||||
});
|
||||
const canReplicateWithPBKDF2WithHost = canReplicateWithPBKDF2.bind(null, this._unresolvedErrorManager, {
|
||||
services: {
|
||||
context: services.context,
|
||||
replicator: services.replicator,
|
||||
setting: services.setting,
|
||||
},
|
||||
serviceModules: {},
|
||||
});
|
||||
services.replication.onBeforeReplicate.addHandler(isOnlineAndCanReplicateWithHost, 10);
|
||||
services.replication.onBeforeReplicate.addHandler(canReplicateWithPBKDF2WithHost, 20);
|
||||
// <-- End of handlers that can be separated.
|
||||
services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this), 100);
|
||||
services.replication.onReplicationFailed.addHandler(this.onReplicationFailed.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { EVENT_SETTING_SAVED, eventHub } from "@/common/events";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
const chunkMocks = vi.hoisted(() => ({
|
||||
purgeUnreferencedChunks: vi.fn(async (_db: unknown, countOnly: boolean) => (countOnly ? 2 : 0)),
|
||||
balanceChunkPurgedDBs: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/chunks", () => chunkMocks);
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
||||
LiveSyncCouchDBReplicator: class {},
|
||||
}));
|
||||
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { ModuleReplicator } from "./ModuleReplicator";
|
||||
|
||||
describe("ModuleReplicator", () => {
|
||||
it("refreshes the remote Security Seed before replication", async () => {
|
||||
const ensurePBKDF2Salt = vi.fn(async () => true);
|
||||
let beforeReplicate: ((showMessage: boolean) => Promise<boolean>) | undefined;
|
||||
const addHandler = vi.fn((handler: (showMessage: boolean) => Promise<boolean>, priority?: number) => {
|
||||
if (priority === 20) {
|
||||
beforeReplicate = handler;
|
||||
}
|
||||
});
|
||||
const services = {
|
||||
API: { isOnline: true },
|
||||
replicator: {
|
||||
onReplicatorInitialised: { addHandler: vi.fn() },
|
||||
getActiveReplicator: () => ({ ensurePBKDF2Salt }),
|
||||
},
|
||||
setting: { currentSettings: () => ({}) },
|
||||
databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } },
|
||||
appLifecycle: { onSettingLoaded: { addHandler: vi.fn() } },
|
||||
replication: {
|
||||
parseSynchroniseResult: { addHandler: vi.fn() },
|
||||
onBeforeReplicate: { addHandler },
|
||||
onReplicationFailed: { addHandler: vi.fn() },
|
||||
},
|
||||
};
|
||||
const module = {
|
||||
_unresolvedErrorManager: {
|
||||
showError: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
},
|
||||
_onReplicatorInitialised: vi.fn(),
|
||||
_everyOnDatabaseInitialized: vi.fn(),
|
||||
_everyOnloadAfterLoadSettings: vi.fn(),
|
||||
_parseReplicationResult: vi.fn(),
|
||||
_everyBeforeReplicate: vi.fn(),
|
||||
onReplicationFailed: vi.fn(),
|
||||
};
|
||||
|
||||
ModuleReplicator.prototype.onBindFunction.call(module, {} as never, services as never);
|
||||
expect(beforeReplicate).toBeDefined();
|
||||
|
||||
await beforeReplicate!(false);
|
||||
|
||||
expect(ensurePBKDF2Salt).toHaveBeenCalledWith({}, false, false);
|
||||
});
|
||||
|
||||
it("reprocesses stored documents when the normal-file target filters change", async () => {
|
||||
eventHub.offAll();
|
||||
const settings = {
|
||||
handleFilenameCaseSensitive: false,
|
||||
ignoreFiles: ".gitignore",
|
||||
maxMTimeForReflectEvents: 0,
|
||||
syncOnlyRegEx: "^E2E/allowed/.*",
|
||||
syncIgnoreRegEx: "",
|
||||
syncInternalFiles: false,
|
||||
syncMaxSizeInMB: 0,
|
||||
suspendParseReplicationResult: false,
|
||||
useIgnoreFiles: false,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
const services = {
|
||||
context: createServiceContext(),
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: { addHandler: vi.fn() },
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
settings,
|
||||
} as any;
|
||||
const module = new ModuleReplicator(core);
|
||||
const reprocessStoredDocuments = vi.fn(async () => 1);
|
||||
Object.assign(module.processor, { reprocessStoredDocuments });
|
||||
|
||||
try {
|
||||
await (module as any)._everyOnloadAfterLoadSettings();
|
||||
eventHub.emitEvent(EVENT_SETTING_SAVED, { ...settings });
|
||||
await Promise.resolve();
|
||||
expect(reprocessStoredDocuments).not.toHaveBeenCalled();
|
||||
|
||||
Object.assign(settings, { syncOnlyRegEx: "" });
|
||||
eventHub.emitEvent(EVENT_SETTING_SAVED, { ...settings });
|
||||
await vi.waitFor(() => expect(reprocessStoredDocuments).toHaveBeenCalledOnce());
|
||||
|
||||
settings.syncMaxSizeInMB = 10;
|
||||
eventHub.emitEvent(EVENT_SETTING_SAVED, { ...settings });
|
||||
await vi.waitFor(() => expect(reprocessStoredDocuments).toHaveBeenCalledTimes(2));
|
||||
} finally {
|
||||
eventHub.offAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", () => {
|
||||
it("keeps its finite replication and balancing work inside the shared activity boundary", async () => {
|
||||
const activityFinished = vi.fn();
|
||||
const runBoundedRemoteActivity = vi.fn(async (task: () => unknown) => {
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
activityFinished();
|
||||
}
|
||||
});
|
||||
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
||||
const openReplication = vi.fn(async () => true);
|
||||
const remoteDatabase = {
|
||||
close: vi.fn(async () => undefined),
|
||||
};
|
||||
const activeReplicator = Object.assign(new LiveSyncCouchDBReplicator({} as any), {
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
});
|
||||
const services = {
|
||||
context: createServiceContext(),
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
isMobile: vi.fn(() => false),
|
||||
},
|
||||
setting: { saveSettingData: vi.fn(async () => undefined) },
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: { addHandler: vi.fn() },
|
||||
},
|
||||
replicator: {
|
||||
getActiveReplicator: vi.fn(() => activeReplicator),
|
||||
runBoundedRemoteActivity,
|
||||
runFiniteReplicationActivity,
|
||||
},
|
||||
};
|
||||
const localDatabase = {
|
||||
localDatabase: {},
|
||||
clearCaches: vi.fn(),
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
settings: {},
|
||||
localDatabase,
|
||||
confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") },
|
||||
replicator: { openReplication },
|
||||
} as any;
|
||||
const module = new ModuleReplicator(core);
|
||||
|
||||
await module.cleaned(true);
|
||||
|
||||
expect(runBoundedRemoteActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||
label: "database-cleanup",
|
||||
});
|
||||
expect(runFiniteReplicationActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||
label: "replication",
|
||||
});
|
||||
expect(openReplication).toHaveBeenCalledOnce();
|
||||
expect(openReplication.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]);
|
||||
expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce();
|
||||
expect(remoteDatabase.close).toHaveBeenCalledOnce();
|
||||
expect(remoteDatabase.close.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
activityFinished.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { REMOTE_MINIO, REMOTE_P2P, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
|
||||
export class ModuleReplicatorCouchDB extends AbstractModule {
|
||||
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
// If new remote types were added, add them here. Do not use `REMOTE_COUCHDB` directly for the safety valve.
|
||||
if (settings.remoteType == REMOTE_MINIO || settings.remoteType == REMOTE_P2P) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return Promise.resolve(new LiveSyncCouchDBReplicator(this.core));
|
||||
}
|
||||
_everyAfterResumeProcess(): Promise<boolean> {
|
||||
if (this.services.appLifecycle.isSuspended()) return Promise.resolve(true);
|
||||
if (!this.services.appLifecycle.isReady()) return Promise.resolve(true);
|
||||
if (this.settings.remoteType != REMOTE_MINIO && this.settings.remoteType != REMOTE_P2P) {
|
||||
const LiveSyncEnabled = this.settings.liveSync;
|
||||
const continuous = LiveSyncEnabled;
|
||||
const eventualOnStart = !LiveSyncEnabled && this.settings.syncOnStart;
|
||||
// If enabled LiveSync or on start, open replication
|
||||
if (LiveSyncEnabled || eventualOnStart) {
|
||||
// And note that we do not open the conflict detection dialogue directly during this process.
|
||||
// This should be raised explicitly if needed.
|
||||
fireAndForget(async () => {
|
||||
const canReplicate = await this.services.replication.isReplicationReady(false);
|
||||
if (!canReplicate) return;
|
||||
const openReplication = () =>
|
||||
this.core.replicator.openReplication(this.settings, continuous, false, false);
|
||||
if (continuous) {
|
||||
void openReplication();
|
||||
} else {
|
||||
await this.services.replicator.runFiniteReplicationActivity(openReplication, {
|
||||
label: "replication",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.replicator.getNewReplicator.addHandler(this._anyNewReplicator.bind(this));
|
||||
services.appLifecycle.onResumed.addHandler(this._everyAfterResumeProcess.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ModuleReplicatorCouchDB } from "./ModuleReplicatorCouchDB.ts";
|
||||
|
||||
function createModule(settings: { liveSync: boolean; syncOnStart: boolean }, isReplicationReady = true) {
|
||||
const openReplication = vi.fn(async () => true);
|
||||
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
appLifecycle: {
|
||||
isSuspended: vi.fn(() => false),
|
||||
isReady: vi.fn(() => true),
|
||||
},
|
||||
replication: {
|
||||
isReplicationReady: vi.fn(async () => isReplicationReady),
|
||||
},
|
||||
replicator: {
|
||||
runFiniteReplicationActivity,
|
||||
},
|
||||
setting: {
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
settings: {
|
||||
remoteType: "",
|
||||
...settings,
|
||||
},
|
||||
replicator: { openReplication },
|
||||
} as any;
|
||||
return {
|
||||
module: new ModuleReplicatorCouchDB(core),
|
||||
openReplication,
|
||||
runFiniteReplicationActivity,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ModuleReplicatorCouchDB resume replication activity", () => {
|
||||
it("exposes start-up one-shot replication as finite replication activity", async () => {
|
||||
const { module, openReplication, runFiniteReplicationActivity } = createModule({
|
||||
liveSync: false,
|
||||
syncOnStart: true,
|
||||
});
|
||||
|
||||
await module._everyAfterResumeProcess();
|
||||
|
||||
await vi.waitFor(() => expect(openReplication).toHaveBeenCalledOnce());
|
||||
expect(runFiniteReplicationActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||
label: "replication",
|
||||
});
|
||||
expect(openReplication).toHaveBeenCalledWith(expect.any(Object), false, false, false);
|
||||
});
|
||||
|
||||
it("does not wrap the unbounded continuous channel in another finite activity", async () => {
|
||||
const { module, openReplication, runFiniteReplicationActivity } = createModule({
|
||||
liveSync: true,
|
||||
syncOnStart: false,
|
||||
});
|
||||
|
||||
await module._everyAfterResumeProcess();
|
||||
|
||||
await vi.waitFor(() => expect(openReplication).toHaveBeenCalledOnce());
|
||||
expect(runFiniteReplicationActivity).not.toHaveBeenCalled();
|
||||
expect(openReplication).toHaveBeenCalledWith(expect.any(Object), true, false, false);
|
||||
});
|
||||
|
||||
it("does not start a one-shot activity when start-up readiness fails", async () => {
|
||||
const { module, openReplication, runFiniteReplicationActivity } = createModule(
|
||||
{
|
||||
liveSync: false,
|
||||
syncOnStart: true,
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
await module._everyAfterResumeProcess();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(runFiniteReplicationActivity).not.toHaveBeenCalled();
|
||||
expect(openReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { REMOTE_MINIO, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
|
||||
export class ModuleReplicatorMinIO extends AbstractModule {
|
||||
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
if (settings.remoteType == REMOTE_MINIO) {
|
||||
return Promise.resolve(new LiveSyncJournalReplicator(this.core));
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.replicator.getNewReplicator.addHandler(this._anyNewReplicator.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat
|
||||
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
export class ModuleConflictResolver extends AbstractModule {
|
||||
private async _resolveConflictByDeletingRev(
|
||||
@@ -142,7 +143,10 @@ export class ModuleConflictResolver extends AbstractModule {
|
||||
//auto resolved, but need check again;
|
||||
if (this.settings.syncAfterMerge && !this.services.appLifecycle.isSuspended()) {
|
||||
//Wait for the running replication, if not running replication, run it once.
|
||||
await this.services.replication.replicateByEvent();
|
||||
await this.services.replication.replicateUnattendedByEvent({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
this._log("[conflict] Automatically merged, but we have to check it again");
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
|
||||
@@ -37,7 +37,7 @@ function createModule(files: FilePathWithPrefix[] = []) {
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
replication: {
|
||||
replicateByEvent: vi.fn(async () => true),
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: {
|
||||
getActiveFilePath: vi.fn(() => undefined),
|
||||
|
||||
@@ -20,6 +20,24 @@ 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 { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
type ReplicationAttemptFailure,
|
||||
type ReplicatorInstance,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
interface PreferredRemoteTweakWriter extends ReplicatorInstance {
|
||||
setPreferredRemoteTweakSettings(setting: ObsidianLiveSyncSettings): Promise<void>;
|
||||
}
|
||||
|
||||
function canSetPreferredRemoteTweakSettings(replicator: ReplicatorInstance): replicator is PreferredRemoteTweakWriter {
|
||||
return (
|
||||
"setPreferredRemoteTweakSettings" in replicator &&
|
||||
typeof replicator.setPreferredRemoteTweakSettings === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Localised counterpart of Commonlib's `confName()`, which takes no translator.
|
||||
@@ -112,11 +130,27 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
});
|
||||
}
|
||||
|
||||
async _anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> {
|
||||
if (!this.core.replicator.tweakSettingsMismatched && !this.core.replicator.preferredTweakValue) return false;
|
||||
const preferred = this.core.replicator.preferredTweakValue;
|
||||
if (!preferred) return false;
|
||||
const ret = await this.services.tweakValue.askResolvingMismatched(preferred);
|
||||
async _anyAfterConnectCheckFailed(failure: ReplicationAttemptFailure): Promise<boolean | "CHECKAGAIN" | undefined> {
|
||||
const recovery = failure.outcome.recoveryHint;
|
||||
if (
|
||||
recovery?.reason !== CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH ||
|
||||
!recovery.preferredTweakValue
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const ret = await this.services.tweakValue.askResolvingMismatched(
|
||||
{ ...recovery.preferredTweakValue },
|
||||
async (setting) => {
|
||||
let updated = false;
|
||||
await this.services.replicator.runWithActiveReplicatorContext(async (activeContext) => {
|
||||
if (activeContext !== failure.context) return;
|
||||
if (!canSetPreferredRemoteTweakSettings(activeContext.replicator)) return;
|
||||
await activeContext.replicator.setPreferredRemoteTweakSettings({ ...setting });
|
||||
updated = true;
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
);
|
||||
if (ret == "OK") return false;
|
||||
if (ret == "CHECKAGAIN") return "CHECKAGAIN";
|
||||
if (ret == "IGNORE") return true;
|
||||
@@ -230,19 +264,23 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
return CHOICES[retKey];
|
||||
}
|
||||
|
||||
async _askResolvingMismatchedTweaks(): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
|
||||
if (!this.core.replicator.tweakSettingsMismatched) {
|
||||
return "OK";
|
||||
}
|
||||
const tweaks = this.core.replicator.preferredTweakValue;
|
||||
if (!tweaks) {
|
||||
return "IGNORE";
|
||||
}
|
||||
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(tweaks);
|
||||
async _askResolvingMismatchedTweaks(
|
||||
preferredSource: TweakValues,
|
||||
updatePreferredRemote?: (setting: ObsidianLiveSyncSettings) => Promise<boolean>
|
||||
): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
|
||||
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(preferredSource);
|
||||
if (!conf) return "IGNORE";
|
||||
|
||||
const updateRemote = async () => {
|
||||
if (updatePreferredRemote) return await updatePreferredRemote(this.settings);
|
||||
const candidate = this.core.replicator;
|
||||
if (typeof candidate.setPreferredRemoteTweakSettings !== "function") return false;
|
||||
await candidate.setPreferredRemoteTweakSettings(this.settings);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (conf === true) {
|
||||
await this.core.replicator.setPreferredRemoteTweakSettings(this.settings);
|
||||
if (!(await updateRemote())) return "IGNORE";
|
||||
if (rebuildRequired) {
|
||||
await this.core.rebuilder.$rebuildRemote();
|
||||
}
|
||||
@@ -259,7 +297,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
// chunk-generation managers now so hash and splitter changes take effect before retrying.
|
||||
await this.localDatabase.managers.reinitialise();
|
||||
}
|
||||
await this.core.replicator.setPreferredRemoteTweakSettings(this.settings);
|
||||
if (!(await updateRemote())) return "IGNORE";
|
||||
if (rebuildRequired) {
|
||||
await this.core.rebuilder.$fetchLocal();
|
||||
}
|
||||
@@ -271,12 +309,15 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
|
||||
async _fetchRemotePreferredTweakValues(trialSetting: RemoteDBSettings): Promise<RemotePreferredTweakResult> {
|
||||
try {
|
||||
const replicator = await this.services.replicator.getNewReplicator(trialSetting);
|
||||
if (!replicator) {
|
||||
const probe = await this.services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK,
|
||||
trialSetting
|
||||
);
|
||||
if (!probe) {
|
||||
this._log("The remote type does not support preferred tweak values.", LOG_LEVEL_NOTICE);
|
||||
return { status: RemotePreferredTweakStatuses.UNSUPPORTED };
|
||||
}
|
||||
return await replicator.getRemotePreferredTweakValues(trialSetting);
|
||||
return await withOwnedRemoteResource(probe, (ownedProbe) => ownedProbe.read());
|
||||
} catch (ex) {
|
||||
this._log("Failed to get the preferred tweak values from the remote.", LOG_LEVEL_NOTICE);
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks";
|
||||
import { setLang } from "@/common/translation";
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
type ReplicationAttemptFailure,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
const askSelectStringDialogue = vi.fn(async (..._args: unknown[]): Promise<string | undefined> => undefined);
|
||||
@@ -55,29 +61,102 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
}
|
||||
|
||||
describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
it("uses the failed attempt hint and writes only through that exact active publication", async () => {
|
||||
const { module, core } = createModule();
|
||||
const attemptPreferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
customChunkSize: 60,
|
||||
};
|
||||
const replacementPreferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
customChunkSize: 99,
|
||||
};
|
||||
let updatePreferredRemote: ((setting: typeof core.settings) => Promise<boolean>) | undefined;
|
||||
const askResolvingMismatched = vi.fn(
|
||||
async (_preferred: unknown, update: (setting: typeof core.settings) => Promise<boolean>) => {
|
||||
updatePreferredRemote = update;
|
||||
return "IGNORE" as const;
|
||||
}
|
||||
);
|
||||
core._services.tweakValue = { askResolvingMismatched };
|
||||
core.replicator = {
|
||||
tweakSettingsMismatched: true,
|
||||
preferredTweakValue: replacementPreferred,
|
||||
};
|
||||
const failedSetPreferred = vi.fn(async (_setting: typeof core.settings) => undefined);
|
||||
const replacementSetPreferred = vi.fn(async (_setting: typeof core.settings) => undefined);
|
||||
const failedContext = {
|
||||
provider: {},
|
||||
replicator: { setPreferredRemoteTweakSettings: failedSetPreferred },
|
||||
configurationIdentity: "profile-a",
|
||||
};
|
||||
const replacementContext = {
|
||||
provider: {},
|
||||
replicator: { setPreferredRemoteTweakSettings: replacementSetPreferred },
|
||||
configurationIdentity: "profile-b",
|
||||
};
|
||||
let activeContext = failedContext;
|
||||
core._services.replicator = {
|
||||
runWithActiveReplicatorContext: vi.fn(async (task: (context: typeof failedContext) => unknown) =>
|
||||
task(activeContext)
|
||||
),
|
||||
};
|
||||
const request = {
|
||||
context: failedContext,
|
||||
setting: core.settings,
|
||||
outcome: {
|
||||
status: "failed" as const,
|
||||
error: new Error("directional replication failed"),
|
||||
recoveryHint: {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH,
|
||||
preferredTweakValue: attemptPreferred,
|
||||
},
|
||||
},
|
||||
showMessage: true,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} as unknown as ReplicationAttemptFailure;
|
||||
|
||||
await expect(module._anyAfterConnectCheckFailed(request)).resolves.toBe(true);
|
||||
|
||||
expect(askResolvingMismatched).toHaveBeenCalledWith(attemptPreferred, expect.any(Function));
|
||||
const effectiveSetting = { ...core.settings, customChunkSize: 64 };
|
||||
await expect(updatePreferredRemote?.(effectiveSetting)).resolves.toBe(true);
|
||||
expect(failedSetPreferred).toHaveBeenCalledWith(effectiveSetting);
|
||||
expect(failedSetPreferred.mock.calls[0][0]).not.toBe(effectiveSetting);
|
||||
|
||||
activeContext = replacementContext;
|
||||
await expect(updatePreferredRemote?.({ ...effectiveSetting, customChunkSize: 72 })).resolves.toBe(false);
|
||||
expect(failedSetPreferred).toHaveBeenCalledOnce();
|
||||
expect(replacementSetPreferred).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an unconfigured remote result without a separate connection preflight", async () => {
|
||||
const { module, core } = createModule();
|
||||
const tryConnectRemote = vi.fn(async () => true);
|
||||
const getRemotePreferredTweakValues = vi.fn(async () => ({
|
||||
const read = vi.fn(async () => ({
|
||||
status: "not-configured" as const,
|
||||
reason: "milestone-missing" as const,
|
||||
}));
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const createRemoteResource = vi.fn(async () => ({ read, dispose }));
|
||||
core._services.replicator = {
|
||||
getNewReplicator: vi.fn(async () => ({ tryConnectRemote, getRemotePreferredTweakValues })),
|
||||
createRemoteResource,
|
||||
getNewReplicator: vi.fn(() => Promise.reject(new Error("must not borrow a Replicator"))),
|
||||
};
|
||||
|
||||
await expect(module._fetchRemotePreferredTweakValues(core.settings)).resolves.toEqual({
|
||||
status: "not-configured",
|
||||
reason: "milestone-missing",
|
||||
});
|
||||
expect(getRemotePreferredTweakValues).toHaveBeenCalledOnce();
|
||||
expect(tryConnectRemote).not.toHaveBeenCalled();
|
||||
expect(createRemoteResource).toHaveBeenCalledWith(REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK, core.settings);
|
||||
expect(read).toHaveBeenCalledOnce();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
expect(core._services.replicator.getNewReplicator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns unsupported when no replicator implements the remote type", async () => {
|
||||
const { module, core } = createModule();
|
||||
core._services.replicator = {
|
||||
getNewReplicator: vi.fn(async () => undefined),
|
||||
createRemoteResource: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
await expect(module._fetchRemotePreferredTweakValues(core.settings)).resolves.toEqual({
|
||||
@@ -85,6 +164,26 @@ describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("disposes the preferred-tweak probe when reading fails", async () => {
|
||||
const { module, core } = createModule();
|
||||
const error = new Error("remote unavailable");
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
core._services.replicator = {
|
||||
createRemoteResource: vi.fn(async () => ({
|
||||
read: vi.fn(async () => {
|
||||
throw error;
|
||||
}),
|
||||
dispose,
|
||||
})),
|
||||
};
|
||||
|
||||
await expect(module._fetchRemotePreferredTweakValues(core.settings)).resolves.toEqual({
|
||||
status: "unavailable",
|
||||
error,
|
||||
});
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should enable and auto-accept compatible mismatches when the preference is undefined", async () => {
|
||||
const { module, core, askSelectStringDialogue, applyPartial } = createModule({
|
||||
autoAcceptCompatibleTweak: undefined,
|
||||
@@ -247,13 +346,18 @@ describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
reinitialise.mockImplementation(async () => {
|
||||
calls.push("reinitialise");
|
||||
});
|
||||
const updatePreferredRemote = vi.fn(async () => {
|
||||
calls.push("set-preferred");
|
||||
return true;
|
||||
});
|
||||
|
||||
const result = await module._askResolvingMismatchedTweaks();
|
||||
const result = await module._askResolvingMismatchedTweaks(preferred, updatePreferredRemote);
|
||||
|
||||
expect(result).toBe("CHECKAGAIN");
|
||||
expect(core.settings).toBe(initialSettings);
|
||||
expect(core.settings.hashAlg).toBe("xxhash32");
|
||||
expect(calls).toEqual(["save", "reinitialise", "set-preferred"]);
|
||||
expect(core.replicator.setPreferredRemoteTweakSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { copyFileDatabaseInfo } from "@/serviceFeatures/fileDatabaseInfo";
|
||||
import { USER_INITIATED_REPLICATION_AUTHORITY } from "@vrtmrz/livesync-commonlib/replication";
|
||||
// Separated Module for basic menu commands, which are not related to obsidian specific features. It is expected to be used in other platforms with minimal changes.
|
||||
// However, it is odd that it has here at all; it really ought to be in each respective feature. It will likely be moved eventually. Until now, addCommand pointed to Obsidian's version.
|
||||
export class ModuleBasicMenu extends AbstractModule {
|
||||
@@ -12,7 +13,10 @@ export class ModuleBasicMenu extends AbstractModule {
|
||||
id: "livesync-replicate",
|
||||
name: $msg("Sync now"),
|
||||
callback: async () => {
|
||||
await this.services.replication.replicate();
|
||||
await this.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
@@ -85,7 +89,7 @@ export class ModuleBasicMenu extends AbstractModule {
|
||||
checkCallback: (checking) => {
|
||||
if (!this.settings.useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
this.core.replicator.terminateSync();
|
||||
fireAndForget(() => this.services.replication.stopActiveTransfer());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -25,7 +25,8 @@ function createFixture() {
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
replication: {
|
||||
replicate: vi.fn(async () => undefined),
|
||||
replicateUserInitiated: vi.fn(async () => ({ status: "completed" as const })),
|
||||
stopActiveTransfer: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: {
|
||||
getActiveFilePath: vi.fn((): string | null => "note.md"),
|
||||
@@ -136,6 +137,19 @@ describe("ModuleBasicMenu command palette", () => {
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true);
|
||||
});
|
||||
|
||||
it("routes an explicit stop through the active provider capability", async () => {
|
||||
const fixture = createFixture();
|
||||
fixture.settings.useAdvancedMode = true;
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(false)).toBe(true);
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.services.replication.stopActiveTransfer).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(fixture.core.replicator.terminateSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps active-file database information available and opens it in a copy dialogue", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
|
||||
@@ -37,6 +37,16 @@ type ErrorInfo = {
|
||||
|
||||
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
|
||||
|
||||
interface CompromisedChunkCounter {
|
||||
countCompromisedChunks(): Promise<number | boolean>;
|
||||
}
|
||||
|
||||
function hasCompromisedChunkCounter(value: object | undefined): value is CompromisedChunkCounter {
|
||||
return (
|
||||
value !== undefined && "countCompromisedChunks" in value && typeof value.countCompromisedChunks === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export class ModuleMigration extends AbstractModule<LiveSyncCore> {
|
||||
constructor(
|
||||
core: LiveSyncCore,
|
||||
@@ -253,7 +263,10 @@ export class ModuleMigration extends AbstractModule<LiveSyncCore> {
|
||||
// Check local database for compromised chunks
|
||||
const localCompromised = await countCompromisedChunks(this.localDatabase.localDatabase);
|
||||
const remote = this.services.replicator.getActiveReplicator();
|
||||
const remoteCompromised = this.services.API.isOnline ? await remote?.countCompromisedChunks() : 0;
|
||||
const remoteCompromised =
|
||||
this.services.API.isOnline && hasCompromisedChunkCounter(remote)
|
||||
? await remote.countCompromisedChunks()
|
||||
: 0;
|
||||
if (localCompromised === false) {
|
||||
Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
type MutableCommandDefinition = {
|
||||
callback?: () => void;
|
||||
@@ -71,7 +72,12 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
} else {
|
||||
if (this.settings.syncOnEditorSave) {
|
||||
this._log("Sync on Editor Save.", LOG_LEVEL_VERBOSE);
|
||||
fireAndForget(() => this.services.replication.replicateByEvent());
|
||||
fireAndForget(() =>
|
||||
this.services.replication.replicateUnattendedByEvent({
|
||||
trigger: "editor-save",
|
||||
interaction: NO_INTERACTION,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -195,11 +201,7 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
|
||||
async watchWindowVisibilityAsync() {
|
||||
if (this.settings.suspendFileWatching) {
|
||||
if (
|
||||
this.settings.isConfigured &&
|
||||
this.services.appLifecycle.isReady() &&
|
||||
this.hasBoundedActivity()
|
||||
) {
|
||||
if (this.settings.isConfigured && this.services.appLifecycle.isReady() && this.hasBoundedActivity()) {
|
||||
const isHidden = activeWindow.document.hidden;
|
||||
this.isLastHidden = isHidden;
|
||||
this.deferredBoundedLifecycle = isHidden ? "suspend-if-hidden" : undefined;
|
||||
@@ -290,7 +292,10 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
return;
|
||||
}
|
||||
if (this.settings.syncOnFileOpen && !this.services.appLifecycle.isSuspended()) {
|
||||
await this.services.replication.replicateByEvent();
|
||||
await this.services.replication.replicateUnattendedByEvent({
|
||||
trigger: "file-open",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
await this.services.conflict.queueCheckForIfOpen(file.path as FilePathWithPrefix);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { addIcon } from "@/deps.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import { USER_INITIATED_REPLICATION_AUTHORITY } from "@vrtmrz/livesync-commonlib/replication";
|
||||
// Obsidian specific menu commands.
|
||||
export class ModuleObsidianMenu extends AbstractModule {
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
@@ -17,7 +18,10 @@ export class ModuleObsidianMenu extends AbstractModule {
|
||||
);
|
||||
|
||||
this.addRibbonIcon("replicate", $msg("moduleObsidianMenu.replicate"), async () => {
|
||||
await this.services.replication.replicate(true);
|
||||
await this.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
}).addClass("livesync-ribbon-replicate");
|
||||
|
||||
return Promise.resolve(true);
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { LiveSyncCore } from "@/main.ts";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR, eventHub } from "@/common/events.ts";
|
||||
import { $msg } from "@/common/translation.ts";
|
||||
import type { Editor, MarkdownFileInfo, MarkdownView } from "@/deps.ts";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
private postponedConflictEpisodes = new Set<FilePathWithPrefix>();
|
||||
@@ -182,7 +183,10 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
// So we have to run replication if configured.
|
||||
// TODO: Make this is as a event request
|
||||
if (this.settings.syncAfterMerge && !this.services.appLifecycle.isSuspended()) {
|
||||
await this.services.replication.replicateByEvent();
|
||||
await this.services.replication.replicateUnattendedByEvent({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
// And, check it again.
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
|
||||
@@ -77,7 +77,9 @@ function createModule(conflictedRevisions: string[] = ["2-right"]) {
|
||||
queueCheckFor: vi.fn(async () => undefined),
|
||||
ensureAllProcessed: vi.fn(async () => true),
|
||||
},
|
||||
replication: { replicateByEvent: vi.fn(async () => true) },
|
||||
replication: {
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: { getActiveFilePath: vi.fn(() => path) },
|
||||
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
|
||||
};
|
||||
|
||||
@@ -13,11 +13,10 @@ import {
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { delay, isObjectDifferent, sizeToHumanReadable } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { checkSyncInfo } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
|
||||
import { testCrypt } from "octagonal-wheels/encryption/encryption";
|
||||
import ObsidianLiveSyncPlugin from "@/main.ts";
|
||||
import { scheduleTask } from "@/common/utils.ts";
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
type AllSettingItemKey,
|
||||
type AllStringItemKey,
|
||||
@@ -78,6 +77,7 @@ import type {
|
||||
import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from "./GeneralSettingSpecs.ts";
|
||||
import { SetupManager } from "@/modules/features/SetupManager.ts";
|
||||
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
|
||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource.ts";
|
||||
|
||||
// For creating a document
|
||||
// const toc = new Set<string>();
|
||||
@@ -340,15 +340,18 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
|
||||
async testConnection(settingOverride: Partial<ObsidianLiveSyncSettings> = {}): Promise<void> {
|
||||
const trialSetting = { ...this.editingSettings, ...settingOverride };
|
||||
const replicator = await this.services.replicator.getNewReplicator(trialSetting);
|
||||
if (!replicator) {
|
||||
Logger("No replicator available for the current settings.", LOG_LEVEL_NOTICE);
|
||||
const probe = await this.services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.CONNECTION,
|
||||
trialSetting
|
||||
);
|
||||
if (!probe) {
|
||||
Logger("Connection testing is unavailable for the current settings.", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
await replicator.tryConnectRemote(trialSetting);
|
||||
const status = await replicator.getRemoteStatus(trialSetting);
|
||||
if (status) {
|
||||
if (status.estimatedSize) {
|
||||
await withOwnedRemoteResource(probe, async (ownedProbe) => {
|
||||
await ownedProbe.check({ createIfMissing: true, showResult: true });
|
||||
const status = await ownedProbe.getStatus();
|
||||
if (status && status.estimatedSize) {
|
||||
Logger(
|
||||
$msg("obsidianLiveSyncSettingTab.logEstimatedSize", {
|
||||
size: sizeToHumanReadable(status.estimatedSize),
|
||||
@@ -356,7 +359,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
closeSetting() {
|
||||
@@ -954,35 +957,42 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
visibility:
|
||||
this.isConfiguredAs("remoteType", REMOTE_COUCHDB) || this.isConfiguredAs("remoteType", REMOTE_MINIO),
|
||||
}) as OnUpdateResult;
|
||||
// E2EE Function
|
||||
/**
|
||||
* Checks the edited CouchDB passphrase through an owned synchronisation-
|
||||
* information resource. A missing document may be created by the check.
|
||||
* Incompatibility and operational failure retain their distinct existing
|
||||
* result messages.
|
||||
*/
|
||||
checkWorkingPassphrase = async (): Promise<boolean> => {
|
||||
if (this.editingSettings.remoteType == REMOTE_MINIO) return true;
|
||||
|
||||
const settingForCheck: RemoteDBSettings = {
|
||||
...this.editingSettings,
|
||||
};
|
||||
const replicator = this.services.replicator.getNewReplicator(settingForCheck);
|
||||
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return true;
|
||||
|
||||
const db = await replicator.connectRemoteCouchDBWithSetting(
|
||||
settingForCheck,
|
||||
this.services.API.isMobile(),
|
||||
true
|
||||
const resource = await this.services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION,
|
||||
settingForCheck
|
||||
);
|
||||
if (typeof db === "string") {
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logCheckPassphraseFailed", { db }), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (!resource) return true;
|
||||
try {
|
||||
if (await checkSyncInfo(db.db)) {
|
||||
if (await resource.check()) {
|
||||
// Logger($msg("obsidianLiveSyncSettingTab.logDatabaseConnected"), LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
} else {
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logPassphraseNotCompatible"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
Logger(
|
||||
$msg("obsidianLiveSyncSettingTab.logCheckPassphraseFailed", {
|
||||
db: reason,
|
||||
}),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
await db.db.close();
|
||||
await resource.dispose();
|
||||
}
|
||||
};
|
||||
isPassphraseValid = async () => {
|
||||
@@ -1199,8 +1209,17 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
new MinioStorageAdapter(this.core.settings, this.core)
|
||||
);
|
||||
}
|
||||
async resetRemoteBucket() {
|
||||
/**
|
||||
* Wipe the remote bucket through a short-lived Journal client.
|
||||
* Journal wipes are batched and non-transactional, so a false result may
|
||||
* leave a partial wipe which can be retried after all devices are stopped.
|
||||
*/
|
||||
async resetRemoteBucket(): Promise<boolean> {
|
||||
const minioJournal = this.getMinioJournalSyncClient();
|
||||
await minioJournal.resetBucket();
|
||||
try {
|
||||
return await minioJournal.resetBucket();
|
||||
} finally {
|
||||
minioJournal.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
const negotiationMocks = vi.hoisted(() => ({
|
||||
checkSyncInfo: vi.fn(async () => true),
|
||||
}));
|
||||
const settingsInitialisationMocks = vi.hoisted(() => ({
|
||||
applySettingsWithInitialisationChoice: vi.fn(),
|
||||
}));
|
||||
const loggerMocks = vi.hoisted(() => ({
|
||||
Logger: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
App: class {},
|
||||
@@ -20,6 +21,14 @@ vi.mock("@/deps.ts", () => ({
|
||||
requireApiVersion: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("@/main.ts", () => ({ default: class {} }));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/compat/common/logger")>();
|
||||
return { ...actual, Logger: loggerMocks.Logger };
|
||||
});
|
||||
vi.mock("@/common/translation", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/common/translation")>();
|
||||
return { ...actual, $msg: vi.fn(actual.$msg) };
|
||||
});
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
|
||||
getLanguage: vi.fn(() => "en"),
|
||||
compatGlobal: {
|
||||
@@ -38,10 +47,6 @@ vi.mock("@/common/events.ts", () => ({
|
||||
eventHub: { emitEvent: vi.fn(), onEvent: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/modules/features/SetupManager.ts", () => ({ SetupManager: class {} }));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => negotiationMocks);
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
||||
LiveSyncCouchDBReplicator: class {},
|
||||
}));
|
||||
vi.mock("./LiveSyncSetting.ts", () => ({ LiveSyncSetting: class {} }));
|
||||
vi.mock("./SettingPane.ts", () => ({
|
||||
enableOnly: vi.fn(() => vi.fn()),
|
||||
@@ -63,27 +68,25 @@ vi.mock("./PanePowerUsers.ts", () => ({ panePowerUsers: vi.fn() }));
|
||||
vi.mock("./PanePatches.ts", () => ({ panePatches: vi.fn() }));
|
||||
vi.mock("./PaneMaintenance.ts", () => ({ paneMaintenance: vi.fn() }));
|
||||
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
|
||||
import { $msg } from "@/common/translation";
|
||||
|
||||
beforeEach(() => {
|
||||
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset();
|
||||
loggerMocks.Logger.mockClear();
|
||||
vi.mocked($msg).mockClear();
|
||||
});
|
||||
|
||||
describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
|
||||
it("closes the finite remote connection after checking synchronisation information", async () => {
|
||||
const remoteDatabase = {
|
||||
close: vi.fn(async () => undefined),
|
||||
};
|
||||
const replicator = Object.assign(new LiveSyncCouchDBReplicator({} as never), {
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })),
|
||||
});
|
||||
it("awaits and disposes the owned synchronisation-information resource", async () => {
|
||||
const check = vi.fn(async () => true);
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const createRemoteResource = vi.fn(async () => ({ check, dispose }));
|
||||
const plugin = {
|
||||
app: {},
|
||||
core: {
|
||||
services: {
|
||||
API: { isMobile: vi.fn(() => false) },
|
||||
replicator: { getNewReplicator: vi.fn(() => replicator) },
|
||||
replicator: { createRemoteResource },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -97,8 +100,160 @@ describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
|
||||
|
||||
await expect(tab.checkWorkingPassphrase()).resolves.toBe(true);
|
||||
|
||||
expect(negotiationMocks.checkSyncInfo).toHaveBeenCalledWith(remoteDatabase);
|
||||
expect(remoteDatabase.close).toHaveBeenCalledOnce();
|
||||
expect(createRemoteResource).toHaveBeenCalledWith(
|
||||
REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION,
|
||||
expect.objectContaining({ remoteType: REMOTE_COUCHDB })
|
||||
);
|
||||
expect(check).toHaveBeenCalledOnce();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not use the general Replicator factory solely to verify synchronisation information", async () => {
|
||||
const getNewReplicator = vi.fn(() => Promise.reject(new Error("must not construct a Replicator")));
|
||||
const createRemoteResource = vi.fn(async () => ({
|
||||
check: vi.fn(async () => true),
|
||||
dispose: vi.fn(async () => undefined),
|
||||
}));
|
||||
const plugin = {
|
||||
app: {},
|
||||
core: {
|
||||
services: {
|
||||
replicator: { createRemoteResource, getNewReplicator },
|
||||
},
|
||||
},
|
||||
};
|
||||
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
|
||||
Object.assign(tab, {
|
||||
_editingSettings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(tab.checkWorkingPassphrase()).resolves.toBe(true);
|
||||
|
||||
expect(getNewReplicator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a CouchDB connection or setup failure with the connection-failure message", async () => {
|
||||
const failure = new Error("remote unavailable");
|
||||
const check = vi.fn(async () => {
|
||||
throw failure;
|
||||
});
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const createRemoteResource = vi.fn(async () => ({ check, dispose }));
|
||||
const plugin = {
|
||||
app: {},
|
||||
core: {
|
||||
services: {
|
||||
replicator: { createRemoteResource },
|
||||
},
|
||||
},
|
||||
};
|
||||
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
|
||||
Object.assign(tab, {
|
||||
_editingSettings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(tab.checkWorkingPassphrase()).resolves.toBe(false);
|
||||
|
||||
expect(vi.mocked($msg)).toHaveBeenCalledWith("obsidianLiveSyncSettingTab.logCheckPassphraseFailed", {
|
||||
db: failure.message,
|
||||
});
|
||||
expect(vi.mocked($msg)).not.toHaveBeenCalledWith("obsidianLiveSyncSettingTab.logPassphraseNotCompatible");
|
||||
expect(loggerMocks.Logger).toHaveBeenCalledWith(expect.any(String), LOG_LEVEL_NOTICE);
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports an actual synchronisation-information mismatch with the incompatibility message", async () => {
|
||||
const check = vi.fn(async () => false);
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const createRemoteResource = vi.fn(async () => ({ check, dispose }));
|
||||
const plugin = {
|
||||
app: {},
|
||||
core: {
|
||||
services: {
|
||||
replicator: { createRemoteResource },
|
||||
},
|
||||
},
|
||||
};
|
||||
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
|
||||
Object.assign(tab, {
|
||||
_editingSettings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(tab.checkWorkingPassphrase()).resolves.toBe(false);
|
||||
|
||||
expect(vi.mocked($msg)).toHaveBeenCalledWith("obsidianLiveSyncSettingTab.logPassphraseNotCompatible");
|
||||
expect(vi.mocked($msg)).not.toHaveBeenCalledWith(
|
||||
"obsidianLiveSyncSettingTab.logCheckPassphraseFailed",
|
||||
expect.anything()
|
||||
);
|
||||
expect(loggerMocks.Logger).toHaveBeenCalledWith(expect.any(String), LOG_LEVEL_NOTICE);
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ObsidianLiveSyncSettingTab connection testing", () => {
|
||||
it("uses and disposes the flow-specific connection probe without borrowing a Replicator", async () => {
|
||||
const check = vi.fn(async () => ({ ok: true as const }));
|
||||
const getStatus = vi.fn(async () => ({ estimatedSize: 1024 }));
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const createRemoteResource = vi.fn(async () => ({ check, getStatus, dispose }));
|
||||
const getNewReplicator = vi.fn(() => Promise.reject(new Error("must not borrow a Replicator")));
|
||||
const plugin = {
|
||||
app: {},
|
||||
core: {
|
||||
services: {
|
||||
replicator: { createRemoteResource, getNewReplicator },
|
||||
},
|
||||
},
|
||||
};
|
||||
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
|
||||
Object.assign(tab, {
|
||||
_editingSettings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
couchDB_DBNAME: "saved",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(tab.testConnection({ couchDB_DBNAME: "trial" })).resolves.toBeUndefined();
|
||||
|
||||
expect(createRemoteResource).toHaveBeenCalledWith(
|
||||
REMOTE_RESOURCE_KINDS.CONNECTION,
|
||||
expect.objectContaining({ remoteType: REMOTE_COUCHDB, couchDB_DBNAME: "trial" })
|
||||
);
|
||||
expect(check).toHaveBeenCalledWith({ createIfMissing: true, showResult: true });
|
||||
expect(getStatus).toHaveBeenCalledOnce();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
expect(getNewReplicator).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ObsidianLiveSyncSettingTab Fresh Start Wipe", () => {
|
||||
it("returns the remote wipe result and disposes its temporary Journal client", async () => {
|
||||
const resetBucket = vi.fn(async () => false);
|
||||
const dispose = vi.fn();
|
||||
const tab = new ObsidianLiveSyncSettingTab(
|
||||
{} as never,
|
||||
{
|
||||
app: {},
|
||||
core: {},
|
||||
} as never
|
||||
);
|
||||
vi.spyOn(tab, "getMinioJournalSyncClient").mockReturnValue({ resetBucket, dispose } as never);
|
||||
|
||||
await expect(tab.resetRemoteBucket()).resolves.toBe(false);
|
||||
|
||||
expect(resetBucket).toHaveBeenCalledOnce();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ export function paneMaintenance(
|
||||
paneEl: HTMLElement,
|
||||
{ addPanel }: PageFunctions
|
||||
): void {
|
||||
const isRemoteLockedAndDeviceNotAccepted = () => this.core?.replicator?.remoteLockedAndDeviceNotAccepted;
|
||||
const isRemoteLocked = () => this.core?.replicator?.remoteLocked;
|
||||
const isRemoteLockedAndDeviceNotAccepted = () => !!this.core?.replicator?.remoteLockedAndDeviceNotAccepted;
|
||||
const isRemoteLocked = () => !!this.core?.replicator?.remoteLocked;
|
||||
// if (this.plugin?.replicator?.remoteLockedAndDeviceNotAccepted) {
|
||||
this.createEl(
|
||||
paneEl,
|
||||
@@ -367,8 +367,13 @@ export function paneMaintenance(
|
||||
sentIDs: new Set(),
|
||||
sentFiles: new Set(),
|
||||
}));
|
||||
await this.resetRemoteBucket();
|
||||
Logger(`Deleted all data on remote server`, LOG_LEVEL_NOTICE);
|
||||
const reset = await this.resetRemoteBucket();
|
||||
Logger(
|
||||
reset
|
||||
? `Deleted all data on remote server`
|
||||
: `Fresh Start Wipe did not complete. Keep all synchronising devices stopped and run it again.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
})
|
||||
)
|
||||
.addOnUpdate(this.onlyOnMinIO);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const maintenanceHarness = vi.hoisted(() => ({
|
||||
createdSettings: [] as Array<{ name: string; click?: () => Promise<void> }>,
|
||||
logger: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_REQUEST_PERFORM_GC_V3: "request-gc-v3",
|
||||
eventHub: { emitEvent: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: (message: string) => message,
|
||||
}));
|
||||
vi.mock("@/serviceFeatures/setupObsidian/settingsReset.ts", () => ({
|
||||
createCoreSettingsAfterFullReset: vi.fn(),
|
||||
createEditingSettingsAfterFullReset: vi.fn(),
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", () => ({
|
||||
LOG_LEVEL_NOTICE: "notice",
|
||||
Logger: maintenanceHarness.logger,
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/types", () => ({
|
||||
FlagFilesHumanReadable: {
|
||||
FETCH_ALL: "fetch-all",
|
||||
REBUILD_ALL: "rebuild-all",
|
||||
},
|
||||
FlagFilesOriginal: { SUSPEND_ALL: "suspend-all" },
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", () => ({
|
||||
fireAndForget: (operation: Promise<unknown>) => operation,
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
||||
LiveSyncCouchDBReplicator: class {},
|
||||
}));
|
||||
vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
LiveSyncSetting: class {
|
||||
name = "";
|
||||
click?: () => Promise<void>;
|
||||
|
||||
constructor() {
|
||||
maintenanceHarness.createdSettings.push(this);
|
||||
}
|
||||
|
||||
setName(name: string) {
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
setDesc() {
|
||||
return this;
|
||||
}
|
||||
|
||||
addButton(callback: (button: this) => void) {
|
||||
callback(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
setButtonText() {
|
||||
return this;
|
||||
}
|
||||
|
||||
setDisabled() {
|
||||
return this;
|
||||
}
|
||||
|
||||
setCta() {
|
||||
return this;
|
||||
}
|
||||
|
||||
onClick(callback: () => Promise<void>) {
|
||||
this.click = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
addOnUpdate() {
|
||||
return this;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("./SettingPane", () => ({
|
||||
visibleOnly: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
vi.mock("./settingComponentStyles.ts", () => ({
|
||||
setButtonDestructiveState: <T>(button: T) => button,
|
||||
}));
|
||||
|
||||
import { paneMaintenance } from "./PaneMaintenance.ts";
|
||||
|
||||
afterEach(() => {
|
||||
maintenanceHarness.createdSettings.length = 0;
|
||||
maintenanceHarness.logger.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("paneMaintenance Fresh Start Wipe", () => {
|
||||
it("does not announce success when the remote wipe reports failure", async () => {
|
||||
const updateCheckPointInfo = vi.fn(async () => undefined);
|
||||
const resetRemoteBucket = vi.fn(async () => false);
|
||||
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (heading === "Rebuilding Operations (Remote Only)") {
|
||||
callback({} as HTMLElement);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
const host = {
|
||||
core: {
|
||||
replicator: {},
|
||||
storageAccess: {},
|
||||
},
|
||||
createEl: vi.fn(),
|
||||
getMinioJournalSyncClient: vi.fn(() => ({ updateCheckPointInfo })),
|
||||
onlyOnCouchDB: vi.fn(),
|
||||
onlyOnCouchDBOrMinIO: vi.fn(),
|
||||
onlyOnMinIO: vi.fn(),
|
||||
resetRemoteBucket,
|
||||
services: {
|
||||
appLifecycle: { performRestart: vi.fn() },
|
||||
database: { resetDatabase: vi.fn() },
|
||||
databaseEvents: { initialiseDatabase: vi.fn() },
|
||||
replication: { markLocked: vi.fn(), markUnlocked: vi.fn() },
|
||||
setting: { saveSettingData: vi.fn() },
|
||||
},
|
||||
};
|
||||
|
||||
paneMaintenance.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
const freshStartWipe = maintenanceHarness.createdSettings.find(({ name }) => name === "Fresh Start Wipe");
|
||||
if (!freshStartWipe?.click) {
|
||||
throw new Error("Fresh Start Wipe action was not registered");
|
||||
}
|
||||
|
||||
await freshStartWipe.click();
|
||||
|
||||
expect(resetRemoteBucket).toHaveBeenCalledOnce();
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith(
|
||||
"Fresh Start Wipe did not complete. Keep all synchronising devices stopped and run it again.",
|
||||
"notice"
|
||||
);
|
||||
expect(maintenanceHarness.logger).not.toHaveBeenCalledWith("Deleted all data on remote server", "notice");
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,6 @@ import type { RemoteConfigurationResult } from "@vrtmrz/livesync-commonlib/compa
|
||||
import SetupRemote from "@/modules/features/SetupWizard/dialogs/SetupRemote.svelte";
|
||||
import SetupRemoteCouchDB from "@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
|
||||
import SetupRemoteBucket from "@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte";
|
||||
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
import type {
|
||||
SetupRemoteCouchDBInitialData,
|
||||
SetupRemoteCouchDBResultType,
|
||||
@@ -217,7 +216,7 @@ export function paneRemoteConfig(
|
||||
}
|
||||
|
||||
if (targetRemoteType === REMOTE_P2P) {
|
||||
const p2pConf = await dialogManager.openWithExplicitCancel(SetupRemoteP2P, baseSettings);
|
||||
const p2pConf = await setupManager.openP2PSetup(baseSettings);
|
||||
if (p2pConf === "cancelled" || typeof p2pConf !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteCouchDBInitialData,
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteP2PInitialData,
|
||||
SetupRemoteP2PResultType,
|
||||
SetupRemoteResultType,
|
||||
UseSetupURIResultType,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
type SetupInitialisationMode,
|
||||
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
|
||||
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
|
||||
import type { P2PConnectionProbeAdmission } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
|
||||
function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
|
||||
return {
|
||||
@@ -94,6 +96,8 @@ export type ApplySettingsWithInitialisationChoiceOptions = {
|
||||
* Setup Manager to handle onboarding and configuration setup
|
||||
*/
|
||||
export class SetupManager extends AbstractModule {
|
||||
private p2pSetupConnectionProbe?: P2PConnectionProbeAdmission;
|
||||
|
||||
// /**
|
||||
// * Dialog manager for handling Svelte dialogs
|
||||
// */
|
||||
@@ -102,6 +106,26 @@ export class SetupManager extends AbstractModule {
|
||||
return this.services.UI.dialogManager;
|
||||
}
|
||||
|
||||
/** Bind the stable P2P owner's probe view to host-owned Setup dialogues. */
|
||||
registerP2PSetupConnectionProbe(connectionProbe: P2PConnectionProbeAdmission): void {
|
||||
if (this.p2pSetupConnectionProbe && this.p2pSetupConnectionProbe !== connectionProbe) {
|
||||
throw new Error("The P2P Setup connection probe has already been registered.");
|
||||
}
|
||||
this.p2pSetupConnectionProbe = connectionProbe;
|
||||
}
|
||||
|
||||
/** Open P2P Setup with the owner-arbitrated connection-probe boundary. */
|
||||
openP2PSetup(settings: P2PSyncSetting): Promise<SetupRemoteP2PResultType> {
|
||||
const connectionProbe = this.p2pSetupConnectionProbe;
|
||||
if (!connectionProbe) {
|
||||
throw new Error("The P2P Setup connection probe is not available.");
|
||||
}
|
||||
return this.dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, SetupRemoteP2PInitialData>(
|
||||
SetupRemoteP2P,
|
||||
{ settings, connectionProbe }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask which existing data should be authoritative for pending setting changes,
|
||||
* then reserve the matching next-start operation before applying them.
|
||||
@@ -280,10 +304,7 @@ export class SetupManager extends AbstractModule {
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
const p2pConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, P2PSyncSetting>(
|
||||
SetupRemoteP2P,
|
||||
currentSetting
|
||||
);
|
||||
const p2pConf = await this.openP2PSetup(currentSetting);
|
||||
if (p2pConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import type {
|
||||
P2PConnectionProbeAdmission,
|
||||
P2PConnectionProbeAdmissionResult,
|
||||
P2PConnectionProbeSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/p2p";
|
||||
|
||||
vi.mock("./SetupWizard/dialogs/Intro.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SelectMethodNewUser.svelte", () => ({ default: {} }));
|
||||
@@ -124,11 +129,23 @@ function createSetupManager() {
|
||||
},
|
||||
});
|
||||
|
||||
const p2pSetupConnectionProbe: P2PConnectionProbeAdmission = {
|
||||
async run<T>(
|
||||
_settings: P2PConnectionProbeSettings,
|
||||
runOwnedTrial: () => Promise<T>
|
||||
): Promise<P2PConnectionProbeAdmissionResult<T>> {
|
||||
return { status: "trial", result: await runOwnedTrial() };
|
||||
},
|
||||
};
|
||||
const manager = new SetupManager(core);
|
||||
manager.registerP2PSetupConnectionProbe(p2pSetupConnectionProbe);
|
||||
|
||||
return {
|
||||
manager: new SetupManager(core),
|
||||
manager,
|
||||
setting,
|
||||
dialogManager,
|
||||
core,
|
||||
p2pSetupConnectionProbe,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,6 +155,19 @@ describe("SetupManager", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("opens P2P Setup with the registered owner admission", async () => {
|
||||
const { manager, setting, dialogManager, p2pSetupConnectionProbe } = createSetupManager();
|
||||
const settings = setting.currentSettings();
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
|
||||
|
||||
await expect(manager.openP2PSetup(settings)).resolves.toBe("cancelled");
|
||||
|
||||
expect(dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ settings, connectionProbe: p2pSetupConnectionProbe })
|
||||
);
|
||||
});
|
||||
|
||||
it("starts manual new-user setup from the recommended new-Vault settings", async () => {
|
||||
const { manager, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually");
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
import { copyTo, pickBucketSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { TYPE_CANCELLED, type SetupRemoteBucketResultType } from "./setupDialogTypes";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
|
||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
const default_setting = pickBucketSyncSettings(DEFAULT_SETTINGS);
|
||||
|
||||
@@ -81,13 +83,18 @@
|
||||
try {
|
||||
processing = true;
|
||||
const trialRemoteSetting = generateSetting();
|
||||
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
|
||||
if (!replicator) {
|
||||
return translateMessage("Failed to create replicator instance.");
|
||||
const probe = await context.services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.CONNECTION,
|
||||
trialRemoteSetting
|
||||
);
|
||||
if (!probe) {
|
||||
return translateMessage("Failed to connect to the server. Please check your settings.");
|
||||
}
|
||||
try {
|
||||
const result = await replicator.tryConnectRemote(trialRemoteSetting, false);
|
||||
if (result) {
|
||||
const result = await withOwnedRemoteResource(probe, (ownedProbe) =>
|
||||
ownedProbe.check({ createIfMissing: true, showResult: false })
|
||||
);
|
||||
if (result.ok) {
|
||||
return "";
|
||||
} else {
|
||||
return translateMessage("Failed to connect to the server. Please check your settings.");
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
} from "./setupDialogTypes";
|
||||
import { isValidCouchDBServerURL, probeCouchDBConnection } from "./couchDBConnectionProbe";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
const default_setting = pickCouchDBSyncSettings(DEFAULT_SETTINGS);
|
||||
|
||||
@@ -73,16 +74,15 @@
|
||||
try {
|
||||
processing = true;
|
||||
const trialRemoteSetting = generateSetting();
|
||||
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
|
||||
if (!replicator) {
|
||||
return translateMessage("Failed to create replicator instance.");
|
||||
const probe = await context.services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.CONNECTION,
|
||||
trialRemoteSetting
|
||||
);
|
||||
if (!probe) {
|
||||
return translateMessage("Failed to connect to the server. Please check your settings.");
|
||||
}
|
||||
try {
|
||||
const result = await probeCouchDBConnection(
|
||||
replicator,
|
||||
trialRemoteSetting,
|
||||
setupMode === "create-or-connect"
|
||||
);
|
||||
const result = await probeCouchDBConnection(probe, setupMode === "create-or-connect");
|
||||
if (result.ok) {
|
||||
return "";
|
||||
} else {
|
||||
|
||||
@@ -36,10 +36,14 @@
|
||||
import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
|
||||
import { TYPE_CANCELLED, type SetupRemoteP2PResultType } from "./setupDialogTypes";
|
||||
import {
|
||||
TYPE_CANCELLED,
|
||||
type SetupRemoteP2PInitialData,
|
||||
type SetupRemoteP2PResultType,
|
||||
} from "./setupDialogTypes";
|
||||
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { probeP2PSetupConnection } from "./p2pSetupConnectionProbe";
|
||||
import { coordinateP2PSetupConnectionProbe, probeP2PSetupConnection } from "./p2pSetupConnectionProbe";
|
||||
|
||||
const default_setting = pickP2PSyncSettings(DEFAULT_SETTINGS);
|
||||
let syncSetting = $state<P2PConnectionInfo>({ ...default_setting });
|
||||
@@ -48,18 +52,18 @@
|
||||
let error = $state("");
|
||||
let connectionPathResetNotice = $state(false);
|
||||
const hasValidTurnServer = $derived(hasValidP2PTurnServerUrl(syncSetting.P2P_turnServers ?? ""));
|
||||
type Props = GuestDialogProps<SetupRemoteP2PResultType, P2PSyncSetting>;
|
||||
type Props = GuestDialogProps<SetupRemoteP2PResultType, SetupRemoteP2PInitialData>;
|
||||
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
let connectionProbe: SetupRemoteP2PInitialData["connectionProbe"] | undefined;
|
||||
onMount(() => {
|
||||
let initialData: P2PSyncSetting | undefined = undefined;
|
||||
if (getInitialData) {
|
||||
initialData = getInitialData();
|
||||
if (initialData) {
|
||||
copyTo(initialData, syncSetting);
|
||||
}
|
||||
const initialData = getInitialData?.();
|
||||
connectionProbe = initialData?.connectionProbe;
|
||||
const initialSettings = initialData?.settings;
|
||||
if (initialSettings) {
|
||||
copyTo(initialSettings, syncSetting);
|
||||
}
|
||||
const initialPeerName = (initialData?.P2P_DevicePeerName ?? "").trim();
|
||||
const initialPeerName = (initialSettings?.P2P_DevicePeerName ?? "").trim();
|
||||
if (initialPeerName !== "") {
|
||||
return;
|
||||
}
|
||||
@@ -97,58 +101,74 @@
|
||||
try {
|
||||
processing = true;
|
||||
const trialRemoteSetting = generateSetting();
|
||||
const map = new Map<string, string>();
|
||||
const store = {
|
||||
get: (key: string) => {
|
||||
return Promise.resolve(map.get(key) || null);
|
||||
},
|
||||
set: (key: string, value: any) => {
|
||||
map.set(key, value);
|
||||
return Promise.resolve();
|
||||
},
|
||||
delete: (key: string) => {
|
||||
map.delete(key);
|
||||
return Promise.resolve();
|
||||
},
|
||||
keys: () => {
|
||||
return Promise.resolve(Array.from(map.keys()));
|
||||
},
|
||||
get db() {
|
||||
return Promise.resolve(this);
|
||||
},
|
||||
} as SimpleStore<any>;
|
||||
|
||||
const dummyPouch = new PouchDB<EntryDoc>("dummy");
|
||||
const env: ReplicatorHostEnv = {
|
||||
events: context.context.events,
|
||||
translate: context.context.translate,
|
||||
settings: trialRemoteSetting,
|
||||
processReplicatedDocs: async (_docs: any[]) => {
|
||||
return;
|
||||
},
|
||||
confirm: context.services.confirm,
|
||||
db: dummyPouch,
|
||||
simpleStore: store,
|
||||
deviceName: syncSetting.P2P_DevicePeerName || "unnamed-device",
|
||||
platform: "setup-wizard",
|
||||
};
|
||||
const replicator = new TrysteroReplicator(env);
|
||||
try {
|
||||
const result = await probeP2PSetupConnection(replicator);
|
||||
if (!result.ok) {
|
||||
return translateMessage("Failed to connect to the signalling relay: ${reason}", {
|
||||
reason: `${result.reason}`,
|
||||
});
|
||||
}
|
||||
return "";
|
||||
} finally {
|
||||
try {
|
||||
await replicator.close();
|
||||
await dummyPouch.destroy();
|
||||
} catch (e) {
|
||||
Logger(e, LOG_LEVEL_VERBOSE, "setup-p2p-cleanup");
|
||||
}
|
||||
const admission = connectionProbe;
|
||||
if (!admission) {
|
||||
throw new Error("The P2P Setup connection probe is not available.");
|
||||
}
|
||||
const result = await coordinateP2PSetupConnectionProbe(admission, trialRemoteSetting, async () => {
|
||||
const map = new Map<string, unknown>();
|
||||
const store = {
|
||||
get: (key: string) => {
|
||||
return Promise.resolve(map.get(key) || null);
|
||||
},
|
||||
set: (key: string, value: unknown) => {
|
||||
map.set(key, value);
|
||||
return Promise.resolve();
|
||||
},
|
||||
delete: (key: string) => {
|
||||
map.delete(key);
|
||||
return Promise.resolve();
|
||||
},
|
||||
keys: () => {
|
||||
return Promise.resolve(Array.from(map.keys()));
|
||||
},
|
||||
get db() {
|
||||
return Promise.resolve(this);
|
||||
},
|
||||
} as SimpleStore<unknown>;
|
||||
|
||||
const dummyPouch = new PouchDB<EntryDoc>("dummy");
|
||||
let replicator: TrysteroReplicator | undefined;
|
||||
try {
|
||||
const env: ReplicatorHostEnv = {
|
||||
events: context.context.events,
|
||||
translate: context.context.translate,
|
||||
settings: trialRemoteSetting,
|
||||
processReplicatedDocs: async (_docs: PouchDB.Core.ExistingDocument<EntryDoc>[]) => {
|
||||
return;
|
||||
},
|
||||
confirm: context.services.confirm,
|
||||
db: dummyPouch,
|
||||
simpleStore: store,
|
||||
deviceName: syncSetting.P2P_DevicePeerName || "unnamed-device",
|
||||
platform: "setup-wizard",
|
||||
};
|
||||
replicator = new TrysteroReplicator(env);
|
||||
return await probeP2PSetupConnection(replicator);
|
||||
} finally {
|
||||
try {
|
||||
await replicator?.dispose();
|
||||
} catch (e) {
|
||||
Logger(e, LOG_LEVEL_VERBOSE, "setup-p2p-replicator-cleanup");
|
||||
}
|
||||
try {
|
||||
await dummyPouch.destroy();
|
||||
} catch (e) {
|
||||
Logger(e, LOG_LEVEL_VERBOSE, "setup-p2p-database-cleanup");
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!result.ok) {
|
||||
if ("kind" in result && result.kind === "blocked") {
|
||||
return translateMessage(
|
||||
"The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing."
|
||||
);
|
||||
}
|
||||
return translateMessage("Failed to connect to the signalling relay: ${reason}", {
|
||||
reason: `${result.reason}`,
|
||||
});
|
||||
}
|
||||
return "";
|
||||
} finally {
|
||||
processing = false;
|
||||
}
|
||||
|
||||
@@ -1,60 +1,14 @@
|
||||
import type {
|
||||
ObsidianLiveSyncSettings,
|
||||
RemoteDBSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
|
||||
|
||||
export type CouchDBConnectionProbeResult = { ok: true } | { ok: false; reason: string };
|
||||
|
||||
type CouchDBConnectionResult =
|
||||
| string
|
||||
| {
|
||||
db: { close(): Promise<void> };
|
||||
info: unknown;
|
||||
};
|
||||
|
||||
export interface CouchDBConnectionProbe {
|
||||
isMobile(): boolean;
|
||||
connectRemoteCouchDBWithSetting(
|
||||
settings: RemoteDBSettings,
|
||||
isMobile: boolean,
|
||||
performSetup: boolean,
|
||||
skipInfo: boolean
|
||||
): CouchDBConnectionResult | Promise<CouchDBConnectionResult>;
|
||||
}
|
||||
|
||||
export function isCouchDBConnectionProbe(value: unknown): value is CouchDBConnectionProbe {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"isMobile" in value &&
|
||||
typeof value.isMobile === "function" &&
|
||||
"connectRemoteCouchDBWithSetting" in value &&
|
||||
typeof value.connectRemoteCouchDBWithSetting === "function"
|
||||
);
|
||||
}
|
||||
import type { RemoteConnectionProbe, RemoteConnectionProbeResult } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
|
||||
|
||||
/** Run the selected CouchDB setup mode within one owned probe lifetime. */
|
||||
export async function probeCouchDBConnection(
|
||||
replicator: unknown,
|
||||
settings: ObsidianLiveSyncSettings,
|
||||
probe: RemoteConnectionProbe,
|
||||
createIfMissing: boolean
|
||||
): Promise<CouchDBConnectionProbeResult> {
|
||||
if (!isCouchDBConnectionProbe(replicator)) {
|
||||
return { ok: false, reason: "The CouchDB connection probe is unavailable." };
|
||||
}
|
||||
const result = await replicator.connectRemoteCouchDBWithSetting(
|
||||
settings,
|
||||
replicator.isMobile(),
|
||||
createIfMissing,
|
||||
false
|
||||
): Promise<RemoteConnectionProbeResult> {
|
||||
return await withOwnedRemoteResource(probe, (ownedProbe) =>
|
||||
ownedProbe.check({ createIfMissing, showResult: false })
|
||||
);
|
||||
if (typeof result === "string") {
|
||||
return { ok: false, reason: result };
|
||||
}
|
||||
try {
|
||||
return { ok: true };
|
||||
} finally {
|
||||
await result.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidCouchDBServerURL(value: string): boolean {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user