From 7aa41baf089a9ba0a282f590fb732803b18388da Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 27 Aug 2026 08:58:48 +0000 Subject: [PATCH 01/43] Document replicator capability and lifecycle contracts --- ...eplicator_capabilities_01_core_contract.md | 516 ++++++++++++++++++ ...r_capabilities_02_p2p_service_lifecycle.md | 301 ++++++++++ ...plicator_capabilities_03_migration_plan.md | 268 +++++++++ 3 files changed, 1085 insertions(+) create mode 100644 docs/adr/2026_08_replicator_capabilities_01_core_contract.md create mode 100644 docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md create mode 100644 docs/adr/2026_08_replicator_capabilities_03_migration_plan.md diff --git a/docs/adr/2026_08_replicator_capabilities_01_core_contract.md b/docs/adr/2026_08_replicator_capabilities_01_core_contract.md new file mode 100644 index 00000000..1bf66f34 --- /dev/null +++ b/docs/adr/2026_08_replicator_capabilities_01_core_contract.md @@ -0,0 +1,516 @@ +--- +date: 2026-08-27 +commonlib-version: "0.1.19" +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 have one LiveSync-owned core ServiceModule as the +replication lifecycle coordinator. It uses `AppLifecycleService`, persisted +settings, `ReplicationService`, and the active support declaration. It does +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 coordinator coalesces duplicate work +within one lifecycle generation and preserves 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 coordinator never calls 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. + +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 coordinator starts one configured Continuous session when supported; +otherwise it may enable the configured generic periodic timer. Continuous has +precedence when both are configured. + +### 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 lifecycle coordinator. 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; +- complete support metadata for the current catalogue; +- active Replicator construction; +- provider configuration identity and an explicit same-kind rebind-or-replace + policy; +- flow-specific probe and initial-transfer dependency factories; and +- provider-specific entry points required by existing current flows. + +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'. + +If a provider caches effective connection settings, its declared rebind or +replacement policy keeps the active adapter current after a same-kind profile +change. Reconciliation is serialised with active work and leaves no old +credentials, Security Seed state, journal checkpoint, adapter cache, or +diagnostic connection reachable from the reconciled handle. + +### Separate exhaustive support metadata from narrow runtime roles + +Every provider definition contains a required support record over the complete +current catalogue. An active Replicator exposes only the narrow roles marked as +supported by its definition. This gives compile-time completeness without a +giant runtime facade or a collection of Boolean flags. + +The generic catalogue covers user-initiated and unattended OneShot Sync, +ordinary long-lived Continuous replication, full upload and download, central +reset and lock administration, preferred-tweak Metadata read and write, +on-demand remote Chunk reads, remote storage status, compromised-Chunk +inspection, central Security Seed, and a request to stop active transfer. + +Full upload/download, reset/lock, and Metadata read/write remain separate roles. +Provider initialisation remains flow-specific: CouchDB may create a database, +whereas Object Storage prepares its Security Seed and does not provision a +bucket. Provider-specific CouchDB maintenance, Object Storage journal +checkpoint maintenance, and P2P room operations are narrowed facets, not +generic feature tests. P2P facets are defined in Part 2. + +Local node identity initialisation is a database and Replicator lifecycle +concern, not a remote capability. 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; +} + +interface UnattendedOneShot { + run(request: { + readonly trigger: UnattendedTrigger; + readonly interaction: typeof NO_INTERACTION; + }): Promise; +} +``` + +`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. + +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 }; +``` + +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. + +### Distinguish observation from an identity result only when required + +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. + +For an observation where an empty value changes a safety decision, use a tagged +result: + +```typescript +type RemoteObservation = + | { readonly kind: "observed"; readonly value: T } + | { readonly kind: "unavailable"; readonly error?: unknown }; +``` + +Compromised-Chunk inspection is the current example. `observed: 0` proves that +the supported inspection found no matching entries; `unavailable` says that it +did not complete, including while offline. A caller must handle every result +before declaring the remote clean. + +Do not wrap every result. When an empty array is the safe, documented identity +for every current caller, retain it. High-frequency Chunk, document, +changes-feed, and queue paths keep arrays, iterators, and scalars unless a +measurement and a safety distinction justify a tag. No wrapper is added per +Chunk, document, changes-feed row, or queue item. + +### Give active ownership and probes explicit boundaries + +`ReplicatorService` is the sole owner of the active Replicator. Replacement is +an atomic transition under its transition lock: + +```text +active -> retiring -> disposed -> replacement published +``` + +Acquisitions wait for the transition and receive only the replacement. A fenced +old handle cannot start work. Disposal stops Continuous activity, awaits work +which cannot be cancelled, and settles or reports every operation owned by the +active adapter before publication. If bounded retirement cannot settle, +replacement fails visibly and no new handle is published; the old handle is +disposed when late work settles. + +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 declares this role `not-implemented` until its lower +level transfer and RPC work have a real stop path. + +`getNewReplicator()` is not a general temporary-instance API. Setup and settings +flows request narrow probes, such as connection, preferred-tweak, or isolated +P2P signalling validation. 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. A probe cannot +replace the active Replicator or the P2P service. + +Streaming Fetch receives an owned CouchDB HTTP configuration and +`RemoteSecuritySeed` supplier directly. The supplier is bound to the selected +configuration identity, invalidates cached seed state on reconciliation, and +is disposed by the initial-transfer flow. It does not construct a temporary +full Replicator. + +### Keep initialisation workflows explicit + +- CouchDB and Object Storage first-device rebuilds reset and lock their central + remote, then perform the established convergence upload. +- A P2P first-device rebuild prepares the local database without pretending to + reset, lock, or upload to a central remote. +- CouchDB and Object Storage Fetch use their central full-download flow. +- A P2P additional-device Fetch selects a peer and performs one full download. +- CouchDB Streaming Fetch remains the separate initial-transfer service above. + +The Rebuilder requests capabilities and does not cast a generic Replicator to a +concrete class. Its `EVENT_DATABASE_REBUILT` continuation is separately +authorised and does not imply `syncOnStart` or P2P AutoStart. + +## 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. + +| Generic 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 | +| Full upload to a central remote | S | S | NA | +| Full download | S | S | S | +| Central remote reset | S | S | NA | +| Central remote lock and resolution | S | S | NA | +| Preferred-tweak Metadata read and write | S | S | NA | +| On-demand remote Chunk source | S | NA | NA | +| Remote storage status | S | S | NA | +| Compromised-Chunk inspection | S | NA | NA | +| Central-remote Security Seed | S | S | NA | +| Request to stop active transfer | S | S | NI | + +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, 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) diff --git a/docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md b/docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md new file mode 100644 index 00000000..26d900b5 --- /dev/null +++ b/docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md @@ -0,0 +1,301 @@ +--- +date: 2026-08-27 +commonlib-version: "0.1.19" +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 accepted [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md) +record remains the description of the current implementation until Stage 3 in +Part 3 is complete. Stage 3 then supersedes only that record's replaceable +LiveSync P2P Replicator and current-result ownership. Its decisions about +serialised room operations, `room.leave()`, Trystero-owned physical peers, and +relay reconnection remain in force. + +## 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 and cancellation signal; +- 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 an internal fence for one room session. It is not a +public capability, a persisted profile identifier, or a synonym for the +logical room. Every callback, snapshot, and operation token carrying +session-bound state is checked against the current epoch. + +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 seven narrow contract views + +The service does not expose a room session, raw host, or concrete Replicator to +ordinary consumers. It supplies these seven views over the same owner: + +1. `P2PTransportLifecycle` observes room state and accepts explicit, + user-owned connect or disconnect requests. +2. `P2PPeerDirectory` supplies peer snapshots and peer arrival or departure. +3. `P2PPeerAdmission` evaluates incoming peers and administers temporary or + persisted acceptance decisions. +4. `P2PTargetedTransfer` performs pull, requested push, and bidirectional + finite synchronisation against an explicit peer. +5. `P2PChangeRelay` administers peer watch and local-change broadcast. +6. `P2PConfigurationExchange` performs peer configuration exchange under its + declared interaction authority. +7. `P2PDiagnostics` supplies status and RTC diagnostics without exposing raw + room or peer connections. + +These are stable service-level contract views, not seven wrapper allocations or +independent 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. An operation +which carries an old epoch returns a stale or blocked result rather than +dispatching into a 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. + +### 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 in-flight transfer de-duplication. 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 its own internal, +session-epoch-bound demand. 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. Demand bookkeeping is +internal to the service and is not a general consumer contract; it cannot turn +a finite transfer into persistent transport policy. + +### Reconcile session settings atomically + +The effective P2P session binding is derived from the selected profile, all +settings which affect transport or session-bound automation, and the current +local database identity. It is not a new persisted profile identifier or a +device-local override. The service reconciles this 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. Replacing a policy-only setting +may cause a temporary disconnect, but it 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 and no explicit-disconnect veto is active. No old listener, credential, +client, or policy demand remains reachable after replacement. + +Reconciliation is serialised with room lifecycle operations: + +1. fence new session work; +2. allow already-started finite transfers to settle, because the current lower + level transfer may not yet have a real cancellation contract; +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 silently interrupts +a transfer without a cancellation contract. 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. + +### De-duplicate by logical lifecycle, not by room epoch + +Baseline AutoSync transfers are de-duplicated by peer and application lifecycle +generation. Trigger provenance and session epoch are not part of the key, so a +reconnect alone cannot repeat an in-flight or completed baseline transfer. + +In-flight de-duplication belongs to the current room session. Completed baseline +history belongs to the stable automation owner and survives transport-only or +policy-only session replacement. Only completed per-peer baselines are settled; +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, active lifecycle generation, 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` | Configured names and advertisements received 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. + +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. + +### Keep probes isolated from the active service + +Setup and settings use a separately owned `P2PConnectionProbe`. It does not +borrow or replace the current room session, and its `dispose()` cannot close the +active service. Because the current implementation may use process-global +relay sockets, a probe must either allocate isolated transport resources or the +host must reject concurrent probing while the active service uses those +sockets. It must not pause or close active relay sockets as a side effect of a +short-lived check. + +### 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, peer, watch, acceptance, transfer, configuration, and diagnostics + have one explicit owner and seven 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. +- 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 probes can validate P2P without mutating the active transport. + +## 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) diff --git a/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md b/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md new file mode 100644 index 00000000..8deec4f9 --- /dev/null +++ b/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md @@ -0,0 +1,268 @@ +--- +date: 2026-08-27 +commonlib-version: "0.1.19" +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 all +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 lifecycle coordinator, remove the resume handler from +`ModuleReplicatorCouchDB`, and route CouchDB Continuous and OneShot Sync plus +Object Storage `syncOnStart` through `ReplicationService`. 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. + +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. + +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 lifecycle coordinator cannot schedule duplicate initial or recurring work. + +## 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. Keep +the P2P stop role `not-implemented` until a lower-level operation exists. Add +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; +- repeated replacement does not retain platform-event subscriptions; +- policy-only change waits for an already-started finite transfer to settle; +- explicit disconnect suppresses AutoStart and relay reconnection until + explicit connect; 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, AutoWatch, and configured-target requests. +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. + +## Stage 5: separate active construction and flow-specific probes + +Make active creation a private, exhaustive `ReplicatorService` operation with +an explicit same-kind rebind-or-replace policy. Migrate every non-active caller +before restricting `getNewReplicator()`: CouchDB connection and passphrase +checks, Object Storage connection and preferred-tweak trials, isolated P2P +Setup, 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. + +## Stage 6: migrate safety-sensitive and provider-specific consumers + +Migrate Setup, Rebuilder, tweak review, remote-size inspection, on-demand Chunk +retrieval, migration inspection, maintenance, and abort commands. Central +mutations settle truthfully before Rebuilder continuation. Compromised-Chunk +inspection uses `observed` or `unavailable`; an offline check is never zero. +Object Storage and P2P no longer supply dummy integrity counts, remote Chunk +results, or Security Seeds through the compatibility facade. + +Move local node identity initialisation out of the provider facade. Add +idempotent asynchronous disposal and settlement tests, same-kind profile +rebind-or-replace tests, and bounded activity around the host-owned garbage- +collection workflow's CouchDB OneShots. Each consumer migration removes the +corresponding generic `remoteType` or `instanceof` feature branch and adds a +focused test. + +Transport-specific settings may continue to display provider kind. Provider +identity is valid for labels and provider-specific configuration; it is not a +generic feature test. + +## Stage 7: retire the giant compatibility facade + +After every current consumer has migrated, stop requiring unsupported methods +on `LiveSyncAbstractReplicator`. Retain or remove compatibility exports at the +next Commonlib compatibility boundary. Do not retain dummy methods only because +the former abstract base declared them. + +## Verification + +### Commonlib unit and type-contract tests + +Cover: + +- exhaustive host-composed definitions for CouchDB, Object Storage, and P2P; +- complete support declarations and matching runtime roles; +- user-initiated, unattended, blocked, partial, cancelled, and failed OneShot + outcomes; +- truthful Object Storage stop or transfer failure and headless P2P outcomes; +- observed and unavailable compromised-Chunk results, including offline; +- active, retiring, disposed, and replacement-published states, including + rejection of new work during retirement and late settlement; +- same-kind rebind or replacement, configuration-identity invalidation, + idempotent asynchronous disposal, and settlement ordering; +- probes which cannot replace the active Replicator or P2P service and dispose + owned resources; +- the deliberately narrow active-transfer stop request, including work it + does not claim to cancel; +- central full-transfer and remote-administration boundaries; and +- provider-specific P2P, CouchDB, and journal facets. + +### 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; +- periodic, database-save, editor-save, file-open, merge, and daemon triggers + remaining free of dialogues; +- manual P2P and configured peer-targeted flows remaining available; +- 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 seven P2P service views sharing one room owner without exposing a raw + host, room, or concrete Replicator; +- 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 probes; +- 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. + +No temporary stage is a release candidate. Release readiness requires the +target capability matrix, production-consumer migration, focused downstream +checks with the exact packed Commonlib artefact, and the real-runtime checks +appropriate to the changed boundary. + +## 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) From 56444bb98b6586bf4faba28caac4a5d4e8357436 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 27 Aug 2026 11:16:30 +0000 Subject: [PATCH 02/43] Route replication through provider capabilities --- ...plicator_capabilities_03_migration_plan.md | 12 + src/LiveSyncBaseCore.ts | 53 +++- .../cli/commands/daemonCommand.unit.spec.ts | 33 +- src/apps/cli/commands/runCommand.ts | 57 +++- src/features/ConfigSync/PluginPane.svelte | 6 +- .../AutomaticReplicationTriggers.unit.spec.ts | 287 ++++++++++++++++++ src/modules/core/ModulePeriodicProcess.ts | 23 +- .../core/ModuleReplicationLifecycle.ts | 113 +++++++ src/modules/core/ModuleReplicator.ts | 28 +- .../core/ModuleReplicator.unit.spec.ts | 54 ++++ src/modules/core/ModuleReplicatorCouchDB.ts | 50 --- .../core/ModuleReplicatorCouchDB.unit.spec.ts | 89 ------ src/modules/core/ModuleReplicatorMinIO.ts | 18 -- .../core/ReplicationLifecycle.unit.spec.ts | 200 ++++++++++++ src/modules/core/ReplicationScheduling.ts | 49 +++ .../coreFeatures/ModuleConflictResolver.ts | 6 +- .../ModuleConflictResolver.unit.spec.ts | 2 +- src/modules/essential/ModuleBasicMenu.ts | 6 +- .../essential/ModuleBasicMenu.unit.spec.ts | 2 +- .../essentialObsidian/ModuleObsidianEvents.ts | 19 +- .../essentialObsidian/ModuleObsidianMenu.ts | 6 +- .../ModuleInteractiveConflictResolver.ts | 6 +- ...leInteractiveConflictResolver.unit.spec.ts | 4 +- 23 files changed, 918 insertions(+), 205 deletions(-) create mode 100644 src/modules/core/AutomaticReplicationTriggers.unit.spec.ts create mode 100644 src/modules/core/ModuleReplicationLifecycle.ts delete mode 100644 src/modules/core/ModuleReplicatorCouchDB.ts delete mode 100644 src/modules/core/ModuleReplicatorCouchDB.unit.spec.ts delete mode 100644 src/modules/core/ModuleReplicatorMinIO.ts create mode 100644 src/modules/core/ReplicationLifecycle.unit.spec.ts create mode 100644 src/modules/core/ReplicationScheduling.ts diff --git a/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md b/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md index 8deec4f9..bc5eb129 100644 --- a/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md +++ b/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md @@ -72,6 +72,18 @@ Replace the factory-registration-only responsibilities of 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 lifecycle coordinator 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. + 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 diff --git a/src/LiveSyncBaseCore.ts b/src/LiveSyncBaseCore.ts index 116d7b06..76f06006 100644 --- a/src/LiveSyncBaseCore.ts +++ b/src/LiveSyncBaseCore.ts @@ -1,7 +1,13 @@ 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 { + REMOTE_COUCHDB, + REMOTE_MINIO, + 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"; @@ -20,8 +26,7 @@ import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/ser 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 { ModuleReplicationLifecycle } from "./modules/core/ModuleReplicationLifecycle"; import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker"; import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver"; import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks"; @@ -30,6 +35,15 @@ 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 { + CAPABILITY_NOT_APPLICABLE, + defineReplicatorProviderDefinitions, + supportedOpenReplicationContinuous, + supportedOpenReplicationOneShot, + supportedOpenReplicationUnattended, +} from "@vrtmrz/livesync-commonlib/replication"; +import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; +import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator"; export class LiveSyncBaseCore< T extends ServiceContext = ServiceContext, @@ -77,6 +91,7 @@ export class LiveSyncBaseCore< featuresInitialiser: (core: LiveSyncBaseCore) => void ) { this._services = serviceHub; + this.registerReplicatorProviders(); this._serviceModules = serviceModuleInitialiser(this, serviceHub); const extraModules = extraModuleInitialiser(this); this.registerModules(extraModules); @@ -136,12 +151,40 @@ export class LiveSyncBaseCore< this.modules.push(module); } + /** Compose the current central providers before any lifecycle event can acquire one. */ + private registerReplicatorProviders() { + const definitions = defineReplicatorProviderDefinitions([REMOTE_COUCHDB, REMOTE_MINIO] as const, { + [REMOTE_COUCHDB]: { + kind: REMOTE_COUCHDB, + diagnosticName: "CouchDB", + isConfigured: (settings) => + settings.remoteType === REMOTE_COUCHDB && + !!settings.couchDB_URI?.trim() && + !!settings.couchDB_DBNAME?.trim(), + create: (_settings) => Promise.resolve(new LiveSyncCouchDBReplicator(this)), + userInitiatedOneShot: supportedOpenReplicationOneShot(), + unattendedOneShot: supportedOpenReplicationUnattended(), + continuous: supportedOpenReplicationContinuous(), + }, + [REMOTE_MINIO]: { + kind: REMOTE_MINIO, + diagnosticName: "Object Storage", + isConfigured: (settings) => + settings.remoteType === REMOTE_MINIO && !!settings.endpoint?.trim() && !!settings.bucket?.trim(), + create: (_settings) => Promise.resolve(new LiveSyncJournalReplicator(this)), + userInitiatedOneShot: supportedOpenReplicationOneShot(), + unattendedOneShot: supportedOpenReplicationUnattended(), + continuous: CAPABILITY_NOT_APPLICABLE, + }, + }); + this.services.replicator.registerReplicatorProviderDefinitions(definitions); + } + 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 ModuleReplicationLifecycle(this)); this._registerModule(new ModuleConflictResolver(this)); this._registerModule(new ModulePeriodicProcess(this)); this._registerModule(new ModuleResolvingMismatchedTweaks(this)); diff --git a/src/apps/cli/commands/daemonCommand.unit.spec.ts b/src/apps/cli/commands/daemonCommand.unit.spec.ts index 5e448ff0..ccd18c70 100644 --- a/src/apps/cli/commands/daemonCommand.unit.spec.ts +++ b/src/apps/cli/commands/daemonCommand.unit.spec.ts @@ -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"; @@ -18,6 +19,7 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager" })); import * as offlineScanner from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner"; +import { getReplicationSchedulingControl } from "@/modules/core/ReplicationScheduling"; function createCoreMock() { const standardIo = { @@ -38,7 +40,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: { @@ -123,6 +125,7 @@ describe("daemon command", () => { await runCommand(makeDaemonOptions(30), { ...baseContext, core }); expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + expect(getReplicationSchedulingControl(core).externalPolling).toBe(true); // Interval should be in milliseconds (30s → 30000ms) expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30000); }); @@ -194,9 +197,9 @@ 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"); @@ -206,11 +209,19 @@ describe("daemon command", () => { await runCommand(makeDaemonOptions(), { ...baseContext, core }); expect(callOrder).toEqual(["replicate", "performFullScan"]); + expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({ + trigger: "daemon", + interaction: NO_INTERACTION, + }); + expect(getReplicationSchedulingControl(core).initialOneShotSatisfied).toBe(true); }); 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 }); @@ -218,6 +229,10 @@ describe("daemon command", () => { 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 () => { @@ -242,11 +257,11 @@ 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; @@ -297,9 +312,9 @@ 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"); }); diff --git a/src/apps/cli/commands/runCommand.ts b/src/apps/cli/commands/runCommand.ts index 0c2e320d..eeb5bc51 100644 --- a/src/apps/cli/commands/runCommand.ts +++ b/src/apps/cli/commands/runCommand.ts @@ -26,6 +26,12 @@ 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 { + isReplicationCompleted, + NO_INTERACTION, + USER_INITIATED_REPLICATION_AUTHORITY, +} from "@vrtmrz/livesync-commonlib/replication"; +import { markInitialOneShotSatisfied, setExternalPollingMode } from "@/modules/core/ReplicationScheduling"; function redactConnectionString(uri: string): string { return uri.replace(/\/\/([^@/]+)@/u, "//***@"); @@ -95,19 +101,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. + setExternalPollingMode(core, !!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"); + markInitialOneShotSatisfied(core); + 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 +144,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 lifecycle coordinator 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 +169,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 +204,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 lifecycle coordinator, 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,8 +226,11 @@ 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. @@ -218,7 +243,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext ); } } - return !!result; + return isReplicationCompleted(result); } if (options.command === "p2p-peers") { diff --git a/src/features/ConfigSync/PluginPane.svelte b/src/features/ConfigSync/PluginPane.svelte index 22e9db7b..cd07fd36 100644 --- a/src/features/ConfigSync/PluginPane.svelte +++ b/src/features/ConfigSync/PluginPane.svelte @@ -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++; diff --git a/src/modules/core/AutomaticReplicationTriggers.unit.spec.ts b/src/modules/core/AutomaticReplicationTriggers.unit.spec.ts new file mode 100644 index 00000000..34920adc --- /dev/null +++ b/src/modules/core/AutomaticReplicationTriggers.unit.spec.ts @@ -0,0 +1,287 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + AUTO_MERGED, + DEFAULT_SETTINGS, + REMOTE_P2P, + type FilePathWithPrefix, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication"; +import { EVENT_FILE_SAVED, eventHub } from "@/common/events"; + +const taskMocks = vi.hoisted(() => ({ + scheduleTask: vi.fn((_key: string, _delay: number, task: () => unknown) => task()), +})); + +vi.mock("octagonal-wheels/concurrency/task", () => taskMocks); + +import { ModuleConflictResolver } from "../coreFeatures/ModuleConflictResolver"; +import { ModuleObsidianEvents } from "../essentialObsidian/ModuleObsidianEvents"; +import { ModulePeriodicProcess } from "./ModulePeriodicProcess"; +import { ModuleReplicationLifecycle } from "./ModuleReplicationLifecycle"; +import { ModuleReplicator } from "./ModuleReplicator"; + +function createApi() { + return { + addLog: vi.fn(), + addCommand: vi.fn(), + registerWindow: vi.fn(), + addRibbonIcon: vi.fn(), + registerProtocolHandler: vi.fn(), + setInterval: vi.fn(), + clearInterval: vi.fn(), + }; +} + +function p2pSettings(overrides: Partial = {}) { + return { + ...DEFAULT_SETTINGS, + remoteType: REMOTE_P2P, + isConfigured: true, + ...overrides, + }; +} + +function createObsidianEventHarness(settings: Partial) { + const save = vi.fn(); + const saveCommand = { callback: save }; + const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const })); + const queueCheckForIfOpen = vi.fn(async () => undefined); + const services = { + API: createApi(), + appLifecycle: { + isReady: vi.fn(() => true), + isSuspended: vi.fn(() => false), + }, + conflict: { queueCheckForIfOpen }, + control: { hasUnloaded: vi.fn(() => false) }, + fileProcessing: { commitPendingFileEvents: vi.fn(async () => true) }, + replication: { replicateUnattendedByEvent }, + }; + const core = { + _services: services, + services, + settings: p2pSettings(settings), + } as any; + const plugin = { + app: { + commands: { + commands: { "editor:save-file": saveCommand }, + executeCommandById: vi.fn(), + }, + }, + } as any; + + return { + module: new ModuleObsidianEvents(plugin, core), + queueCheckForIfOpen, + replicateUnattendedByEvent, + save, + saveCommand, + services, + }; +} + +describe("automatic replication triggers while P2P is active", () => { + afterEach(() => { + eventHub.offAll(); + taskMocks.scheduleTask.mockClear(); + }); + + it("keeps periodic synchronisation on the provider-independent replication boundary", async () => { + const replicateUnattended = vi.fn(async () => ({ status: "completed" as const })); + const services = { + API: createApi(), + control: { hasUnloaded: vi.fn(() => false) }, + replication: { replicateUnattended }, + }; + const core = { + _services: services, + services, + settings: p2pSettings({ periodicReplication: true }), + } as any; + const module = new ModulePeriodicProcess(core); + + await module.periodicSyncProcessor.process(); + + expect(replicateUnattended).toHaveBeenCalledOnce(); + expect(replicateUnattended).toHaveBeenCalledWith({ + trigger: "periodic", + interaction: NO_INTERACTION, + }); + }); + + it("keeps database-save synchronisation on the event replication boundary", async () => { + const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const })); + const settings = p2pSettings({ syncOnSave: true }); + const services = { + appLifecycle: { isSuspended: vi.fn(() => false) }, + replication: { replicateUnattendedByEvent }, + }; + const module = { + core: { services, settings }, + services, + settings, + getNormalFileReflectionFilterSignature: ( + ModuleReplicator.prototype as unknown as { + getNormalFileReflectionFilterSignature: (value: typeof settings) => string; + } + ).getNormalFileReflectionFilterSignature, + }; + + await (ModuleReplicator.prototype as any)._everyOnloadAfterLoadSettings.call(module); + eventHub.emitEvent(EVENT_FILE_SAVED); + + await vi.waitFor(() => expect(replicateUnattendedByEvent).toHaveBeenCalledOnce()); + expect(replicateUnattendedByEvent).toHaveBeenCalledWith({ + trigger: "database-event", + interaction: NO_INTERACTION, + }); + }); + + it("keeps editor-save synchronisation on the event replication boundary", async () => { + const { module, replicateUnattendedByEvent, save, saveCommand } = createObsidianEventHarness({ + syncOnEditorSave: true, + }); + + module.swapSaveCommand(); + saveCommand.callback(); + + expect(save).toHaveBeenCalledOnce(); + await vi.waitFor(() => expect(replicateUnattendedByEvent).toHaveBeenCalledOnce()); + expect(replicateUnattendedByEvent).toHaveBeenCalledWith({ + trigger: "editor-save", + interaction: NO_INTERACTION, + }); + }); + + it("keeps file-open synchronisation on the event replication boundary", async () => { + const { module, queueCheckForIfOpen, replicateUnattendedByEvent, services } = createObsidianEventHarness({ + syncOnFileOpen: true, + }); + const file = { path: "opened.md" } as never; + + await module.watchWorkspaceOpenAsync(file); + + expect(services.fileProcessing.commitPendingFileEvents).toHaveBeenCalledOnce(); + expect(replicateUnattendedByEvent).toHaveBeenCalledOnce(); + expect(replicateUnattendedByEvent).toHaveBeenCalledWith({ + trigger: "file-open", + interaction: NO_INTERACTION, + }); + expect(queueCheckForIfOpen).toHaveBeenCalledWith("opened.md"); + }); + + it("keeps post-merge synchronisation on the event replication boundary", async () => { + const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const })); + const queueCheckFor = vi.fn(async () => undefined); + const path = "merged.md" as FilePathWithPrefix; + const module = { + settings: p2pSettings({ syncAfterMerge: true }), + services: { + appLifecycle: { isSuspended: vi.fn(() => false) }, + conflict: { queueCheckFor }, + replication: { replicateUnattendedByEvent }, + }, + checkConflictAndPerformAutoMerge: vi.fn(async () => AUTO_MERGED), + _log: vi.fn(), + }; + + await (ModuleConflictResolver.prototype as any)._resolveConflict.call(module, path); + + expect(replicateUnattendedByEvent).toHaveBeenCalledOnce(); + expect(replicateUnattendedByEvent).toHaveBeenCalledWith({ + trigger: "merge", + interaction: NO_INTERACTION, + }); + expect(queueCheckFor).toHaveBeenCalledWith(path); + }); +}); + +describe("recurring replication scheduling precedence", () => { + afterEach(() => { + eventHub.offAll(); + }); + + function createRecurringSchedulingHarness() { + const resumeHandlers: Array<() => Promise> = []; + const settingRealisedHandlers: Array<() => Promise> = []; + let resolveContinuous!: ( + outcome: { status: "completed" } | { status: "blocked"; reason: "capability-not-applicable" } + ) => void; + const startContinuous = vi.fn( + () => + new Promise<{ status: "completed" } | { status: "blocked"; reason: "capability-not-applicable" }>( + (resolve) => { + resolveContinuous = resolve; + } + ) + ); + const API = createApi(); + const settings = { + ...DEFAULT_SETTINGS, + isConfigured: true, + liveSync: true, + syncOnStart: true, + periodicReplication: true, + periodicReplicationInterval: 60, + }; + const services = { + API, + appLifecycle: { + isReady: vi.fn(() => true), + isSuspended: vi.fn(() => false), + onResumed: { addHandler: vi.fn((handler: () => Promise) => resumeHandlers.push(handler)) }, + onSuspending: { addHandler: vi.fn() }, + onUnload: { addHandler: vi.fn() }, + }, + control: { hasUnloaded: vi.fn(() => false) }, + replication: { + startContinuous, + replicateUnattended: vi.fn(async () => ({ status: "completed" as const })), + }, + setting: { + currentSettings: vi.fn(() => settings), + onBeforeRealiseSetting: { addHandler: vi.fn() }, + onSettingRealised: { + addHandler: vi.fn((handler: () => Promise) => settingRealisedHandlers.push(handler)), + }, + }, + }; + const core = { _services: services, services, settings } as any; + const lifecycle = new ModuleReplicationLifecycle(core); + const periodic = new ModulePeriodicProcess(core); + lifecycle.onBindFunction(core, services as never); + periodic.onBindFunction(core, services as never); + + return { + API, + resolveContinuous: ( + outcome: { status: "completed" } | { status: "blocked"; reason: "capability-not-applicable" } + ) => resolveContinuous(outcome), + resume: async () => await Promise.all(resumeHandlers.map(async (handler) => await handler())), + realiseSettings: async () => + await Promise.all(settingRealisedHandlers.map(async (handler) => await handler())), + }; + } + + it("does not enable the generic periodic timer while Continuous owns recurring synchronisation", async () => { + const harness = createRecurringSchedulingHarness(); + + await harness.resume(); + await harness.realiseSettings(); + + expect(harness.API.setInterval).not.toHaveBeenCalled(); + harness.resolveContinuous({ status: "completed" }); + await vi.waitFor(() => expect(harness.API.setInterval).not.toHaveBeenCalled()); + }); + + it("restores the generic periodic timer when Continuous is not applicable", async () => { + const harness = createRecurringSchedulingHarness(); + + await harness.resume(); + await harness.realiseSettings(); + harness.resolveContinuous({ status: "blocked", reason: "capability-not-applicable" }); + + await vi.waitFor(() => expect(harness.API.setInterval).toHaveBeenCalledOnce()); + }); +}); diff --git a/src/modules/core/ModulePeriodicProcess.ts b/src/modules/core/ModulePeriodicProcess.ts index 9d8027bc..f7cd8486 100644 --- a/src/modules/core/ModulePeriodicProcess.ts +++ b/src/modules/core/ModulePeriodicProcess.ts @@ -1,15 +1,27 @@ import { PeriodicProcessor } from "@/common/PeriodicProcessor"; import type { LiveSyncCore } from "@/main"; import { AbstractModule } from "@/modules/AbstractModule"; +import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication"; +import { getReplicationSchedulingControl } from "./ReplicationScheduling"; export class ModulePeriodicProcess extends AbstractModule { - periodicSyncProcessor = new PeriodicProcessor(this.core, async () => await this.services.replication.replicate()); + private readonly schedulingControl = getReplicationSchedulingControl(this.core); + periodicSyncProcessor = new PeriodicProcessor(this.core, async () => { + await this.services.replication.replicateUnattended({ + trigger: "periodic", + interaction: NO_INTERACTION, + }); + }); disablePeriodic() { this.periodicSyncProcessor?.disable(); return Promise.resolve(true); } resumePeriodic() { + if (this.schedulingControl.externalPolling || this.schedulingControl.continuousOwnsRecurring) { + void this.disablePeriodic(); + return Promise.resolve(true); + } this.periodicSyncProcessor.enable( this.settings.periodicReplication ? this.settings.periodicReplicationInterval * 1000 : 0 ); @@ -32,6 +44,15 @@ export class ModulePeriodicProcess extends AbstractModule { } override onBindFunction(core: LiveSyncCore, services: typeof core.services): void { + this.schedulingControl.disablePeriodic = () => { + void this.disablePeriodic(); + }; + this.schedulingControl.refreshPeriodic = () => { + void this.resumePeriodic(); + }; + if (this.schedulingControl.externalPolling || this.schedulingControl.continuousOwnsRecurring) { + void this.disablePeriodic(); + } services.appLifecycle.onUnload.addHandler(this._allOnUnload.bind(this)); services.setting.onBeforeRealiseSetting.addHandler(this._everyBeforeRealizeSetting.bind(this)); services.setting.onSettingRealised.addHandler(this._everyAfterRealizeSetting.bind(this)); diff --git a/src/modules/core/ModuleReplicationLifecycle.ts b/src/modules/core/ModuleReplicationLifecycle.ts new file mode 100644 index 00000000..1e8c4d4b --- /dev/null +++ b/src/modules/core/ModuleReplicationLifecycle.ts @@ -0,0 +1,113 @@ +import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger"; +import { + isReplicationCompleted, + NO_INTERACTION, + type ReplicationOutcome, +} from "@vrtmrz/livesync-commonlib/replication"; +import { AbstractModule } from "@/modules/AbstractModule"; +import type { LiveSyncCore } from "@/main"; +import { + getReplicationSchedulingControl, + markInitialOneShotSatisfied, + setContinuousSchedulingOwnership, + setExternalPollingMode, +} from "./ReplicationScheduling"; + +function isCapabilityUnavailable(result: ReplicationOutcome): boolean { + return ( + result.status === "blocked" && + (result.reason === "capability-not-applicable" || result.reason === "capability-not-implemented") + ); +} + +/** + * Coordinates application resume with the active provider's typed roles. + * Provider implementations do not subscribe to the application lifecycle. + */ +export class ModuleReplicationLifecycle extends AbstractModule { + private readonly schedulingControl = getReplicationSchedulingControl(this.core); + private resumePromise?: Promise; + + private async runAfterResume(): Promise { + if (this.schedulingControl.externalPolling) return true; + if (this.services.appLifecycle.isSuspended()) return true; + if (!this.services.appLifecycle.isReady()) return true; + + const settings = this.services.setting.currentSettings(); + if (!settings.isConfigured) { + setContinuousSchedulingOwnership(this.core, false); + return true; + } + + const skipOneShot = this.schedulingControl.initialOneShotSatisfied; + if (settings.liveSync) { + // Reserve recurring ownership before the asynchronous start so a + // later resume handler cannot enable Periodic in the meantime. + setContinuousSchedulingOwnership(this.core, true); + const result = await this.services.replication.startContinuous({ + trigger: "resume", + interaction: NO_INTERACTION, + }); + if (!isReplicationCompleted(result)) { + setContinuousSchedulingOwnership(this.core, false); + } + // The daemon's initial finite convergence must not suppress a + // supported Continuous start. It only suppresses the fallback + // OneShot when Continuous is unavailable. + this.schedulingControl.initialOneShotSatisfied = false; + if (isCapabilityUnavailable(result) && settings.syncOnStart && !skipOneShot) { + await this.services.replication.replicateUnattended({ + trigger: "resume", + interaction: NO_INTERACTION, + }); + } + return true; + } + + setContinuousSchedulingOwnership(this.core, false); + if (settings.syncOnStart && !skipOneShot) { + await this.services.replication.replicateUnattended({ + trigger: "resume", + interaction: NO_INTERACTION, + }); + } + this.schedulingControl.initialOneShotSatisfied = false; + return true; + } + + private _everyAfterResumeProcess(): Promise { + if (!this.resumePromise) { + // The lifecycle event is a short notification boundary. Keep the + // long-running OneShot/Continuous start coalesced internally, but + // let later resume handlers (P2P, periodic scheduling, and other + // modules) continue without waiting for network work to settle. + this.resumePromise = this.runAfterResume() + .catch((error) => { + this._log(error, LOG_LEVEL_VERBOSE); + return true; + }) + .finally(() => { + this.resumePromise = undefined; + }); + } + return Promise.resolve(true); + } + + /** + * Let a CLI daemon own recurring polling without a duplicate lifecycle or + * generic periodic scheduler. This is intentionally narrower than a + * provider or Replicator control API. + */ + setExternalPollingMode(enabled: boolean): void { + setExternalPollingMode(this.core, enabled); + } + + /** Mark the daemon's initial finite convergence for the next resume. */ + markInitialOneShotSatisfied(): void { + markInitialOneShotSatisfied(this.core); + } + + override onBindFunction(core: LiveSyncCore, services: typeof core.services): void { + services.appLifecycle.onResumed.addHandler(this._everyAfterResumeProcess.bind(this)); + } +} diff --git a/src/modules/core/ModuleReplicator.ts b/src/modules/core/ModuleReplicator.ts index 4d2b352a..5bf68965 100644 --- a/src/modules/core/ModuleReplicator.ts +++ b/src/modules/core/ModuleReplicator.ts @@ -23,6 +23,7 @@ import { clearHandlers } from "@vrtmrz/livesync-commonlib/compat/replication/Syn 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"; +import { NO_INTERACTION, type ReplicationInteraction } from "@vrtmrz/livesync-commonlib/replication"; function isOnlineAndCanReplicate( errorManager: UnresolvedErrorManager, @@ -108,7 +109,12 @@ export class ModuleReplicator extends AbstractModule { 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()); + scheduleTask("perform-replicate-after-save", 250, () => + this.services.replication.replicateUnattendedByEvent({ + trigger: "database-event", + interaction: NO_INTERACTION, + }) + ); } }); eventHub.onEvent(EVENT_SETTING_SAVED, (setting) => { @@ -223,12 +229,30 @@ Even if you choose to clean up, you will see this option again if you exit Obsid }); } - private async onReplicationFailed(showMessage: boolean = false): Promise { + private async onReplicationFailed( + showMessageOrInteraction: boolean | ReplicationInteraction = false, + interaction?: ReplicationInteraction + ): Promise { + // The typed ReplicationService passes the legacy visibility flag first + // and the authority second. The authority is the source of truth for + // recovery dialogues when it is present; retain the legacy boolean for + // older callers which do not provide one. + const showMessage = interaction + ? interaction.kind === "permitted" && interaction.permissions.failureRecovery + : typeof showMessageOrInteraction === "boolean" + ? showMessageOrInteraction + : showMessageOrInteraction.kind === "permitted" && showMessageOrInteraction.permissions.failureRecovery; const activeReplicator = this.services.replicator.getActiveReplicator(); if (!activeReplicator) { Logger(`No active replicator found`, LOG_LEVEL_INFO); return false; } + if (!showMessage) { + // Automatic requests may report the failure, but they must never + // enter tweak, lock, fetch, unlock, or cleanup dialogues. + Logger(`Replication failed on an unattended path.`, LOG_LEVEL_INFO); + return false; + } if (activeReplicator.tweakSettingsMismatched && activeReplicator.preferredTweakValue) { await this.services.tweakValue.askResolvingMismatched(activeReplicator.preferredTweakValue); } else { diff --git a/src/modules/core/ModuleReplicator.unit.spec.ts b/src/modules/core/ModuleReplicator.unit.spec.ts index 668c94bc..9236e6d4 100644 --- a/src/modules/core/ModuleReplicator.unit.spec.ts +++ b/src/modules/core/ModuleReplicator.unit.spec.ts @@ -114,6 +114,60 @@ describe("ModuleReplicator", () => { eventHub.offAll(); } }); + + it("only permits recovery dialogue when the authority grants failure recovery", async () => { + const askResolvingMismatched = vi.fn(async () => undefined); + const activeReplicator = { + tweakSettingsMismatched: true, + preferredTweakValue: { customChunkSize: 60 }, + }; + 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() }, + }, + replicator: { getActiveReplicator: vi.fn(() => activeReplicator) }, + tweakValue: { askResolvingMismatched }, + }; + const core = { + _services: services, + services, + settings: {}, + } as any; + const module = new ModuleReplicator(core); + + await (module as any).onReplicationFailed(false); + expect(askResolvingMismatched).not.toHaveBeenCalled(); + + await (module as any).onReplicationFailed(true, { + kind: "permitted", + permissions: { + peerSelection: true, + localPeerAdmission: true, + configurationExchange: true, + failureRecovery: false, + }, + }); + expect(askResolvingMismatched).not.toHaveBeenCalled(); + + await (module as any).onReplicationFailed(true, { + kind: "permitted", + permissions: { + peerSelection: true, + localPeerAdmission: true, + configurationExchange: true, + failureRecovery: true, + }, + }); + expect(askResolvingMismatched).toHaveBeenCalledOnce(); + }); }); describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", () => { diff --git a/src/modules/core/ModuleReplicatorCouchDB.ts b/src/modules/core/ModuleReplicatorCouchDB.ts deleted file mode 100644 index d96fb5c0..00000000 --- a/src/modules/core/ModuleReplicatorCouchDB.ts +++ /dev/null @@ -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 = {}): Promise { - 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 { - 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)); - } -} diff --git a/src/modules/core/ModuleReplicatorCouchDB.unit.spec.ts b/src/modules/core/ModuleReplicatorCouchDB.unit.spec.ts deleted file mode 100644 index c4296e19..00000000 --- a/src/modules/core/ModuleReplicatorCouchDB.unit.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/modules/core/ModuleReplicatorMinIO.ts b/src/modules/core/ModuleReplicatorMinIO.ts deleted file mode 100644 index 68dcc22e..00000000 --- a/src/modules/core/ModuleReplicatorMinIO.ts +++ /dev/null @@ -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 = {}): Promise { - 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)); - } -} diff --git a/src/modules/core/ReplicationLifecycle.unit.spec.ts b/src/modules/core/ReplicationLifecycle.unit.spec.ts new file mode 100644 index 00000000..9e274973 --- /dev/null +++ b/src/modules/core/ReplicationLifecycle.unit.spec.ts @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from "vitest"; +import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { NO_INTERACTION, type ReplicationOutcome } from "@vrtmrz/livesync-commonlib/replication"; +import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import { ModuleReplicationLifecycle } from "./ModuleReplicationLifecycle"; +import { getReplicationSchedulingControl, setExternalPollingMode } from "./ReplicationScheduling"; + +type ResumeHandler = () => Promise; + +function createResumeHarness(settings: { + liveSync: boolean; + syncOnStart: boolean; + isConfigured?: boolean; + remoteType?: string; + P2P_Enabled?: boolean; +}) { + const resumeHandlers: ResumeHandler[] = []; + const replicateUnattended = vi.fn(async (): Promise => ({ status: "completed" })); + const startContinuous = vi.fn(async (): Promise => ({ status: "completed" })); + const currentSettings = { + isConfigured: true, + periodicReplication: false, + ...settings, + }; + const services = { + context: createServiceContext(), + API: { + addLog: vi.fn(), + addCommand: vi.fn(), + registerWindow: vi.fn(), + addRibbonIcon: vi.fn(), + registerProtocolHandler: vi.fn(), + isOnline: true, + }, + appLifecycle: { + isReady: vi.fn(() => true), + isSuspended: vi.fn(() => false), + onResumed: { + addHandler: vi.fn((handler: ResumeHandler) => resumeHandlers.push(handler)), + }, + }, + replication: { + replicateUnattended, + startContinuous, + }, + setting: { + currentSettings: vi.fn(() => currentSettings), + }, + }; + const core = { + _services: services, + services, + settings: currentSettings, + } as any; + const module = new ModuleReplicationLifecycle(core); + module.onBindFunction(core, services as never); + + return { + core, + module, + replicateUnattended, + startContinuous, + resume: async () => await Promise.all(resumeHandlers.map((handler) => handler())), + }; +} + +describe("provider-independent replication resume lifecycle", () => { + it("starts one unattended OneShot when sync-on-start is enabled", async () => { + const harness = createResumeHarness({ liveSync: false, syncOnStart: true }); + + await harness.resume(); + + expect(harness.replicateUnattended).toHaveBeenCalledOnce(); + expect(harness.replicateUnattended).toHaveBeenCalledWith({ + trigger: "resume", + interaction: NO_INTERACTION, + }); + expect(harness.startContinuous).not.toHaveBeenCalled(); + }); + + it("falls back to sync-on-start when Continuous is not applicable", async () => { + const harness = createResumeHarness({ liveSync: true, syncOnStart: true }); + harness.startContinuous.mockResolvedValue({ + status: "blocked", + reason: "capability-not-applicable", + }); + + await harness.resume(); + + expect(harness.startContinuous).toHaveBeenCalledWith({ + trigger: "resume", + interaction: NO_INTERACTION, + }); + expect(harness.replicateUnattended).toHaveBeenCalledWith({ + trigger: "resume", + interaction: NO_INTERACTION, + }); + }); + + it("starts Continuous without a finite fallback when it is supported", async () => { + const harness = createResumeHarness({ liveSync: true, syncOnStart: true }); + + await harness.resume(); + + expect(harness.startContinuous).toHaveBeenCalledOnce(); + expect(harness.replicateUnattended).not.toHaveBeenCalled(); + }); + + it("does not fall back after an actual Continuous failure", async () => { + const harness = createResumeHarness({ liveSync: true, syncOnStart: true }); + harness.startContinuous.mockResolvedValue({ + status: "failed", + error: new Error("connection failed"), + }); + + await harness.resume(); + + expect(harness.replicateUnattended).not.toHaveBeenCalled(); + }); + + it("coalesces concurrent resume callbacks", async () => { + const harness = createResumeHarness({ liveSync: false, syncOnStart: true }); + let resolveReplication!: (value: { status: "completed" }) => void; + harness.replicateUnattended.mockImplementationOnce( + () => new Promise((resolve) => (resolveReplication = resolve)) + ); + + const first = harness.resume(); + const second = harness.resume(); + resolveReplication({ status: "completed" }); + await Promise.all([first, second]); + + expect(harness.replicateUnattended).toHaveBeenCalledOnce(); + }); + + it("does not block later resume handlers while a OneShot is running", async () => { + const harness = createResumeHarness({ liveSync: false, syncOnStart: true }); + let resolveReplication!: (value: { status: "completed" }) => void; + harness.replicateUnattended.mockImplementationOnce( + () => new Promise((resolve) => (resolveReplication = resolve)) + ); + + const resumed = harness.resume(); + await expect(resumed).resolves.toEqual([true]); + expect(harness.replicateUnattended).toHaveBeenCalledOnce(); + + resolveReplication({ status: "completed" }); + await resumed; + }); + + it("skips only the daemon-satisfied OneShot while allowing Continuous", async () => { + const harness = createResumeHarness({ liveSync: true, syncOnStart: true }); + getReplicationSchedulingControl(harness.core).initialOneShotSatisfied = true; + harness.startContinuous.mockResolvedValue({ + status: "blocked", + reason: "capability-not-applicable", + }); + + await harness.resume(); + + expect(harness.startContinuous).toHaveBeenCalledOnce(); + expect(harness.replicateUnattended).not.toHaveBeenCalled(); + expect(getReplicationSchedulingControl(harness.core).initialOneShotSatisfied).toBe(false); + }); + + it("does not start lifecycle replication while an external poller owns scheduling", async () => { + const harness = createResumeHarness({ liveSync: false, syncOnStart: true }); + setExternalPollingMode(harness.core, true); + + await harness.resume(); + + expect(harness.startContinuous).not.toHaveBeenCalled(); + expect(harness.replicateUnattended).not.toHaveBeenCalled(); + }); + + it("requests the generic finite fallback for P2P when Continuous is not applicable", async () => { + const harness = createResumeHarness({ + remoteType: REMOTE_P2P, + P2P_Enabled: true, + liveSync: true, + syncOnStart: true, + }); + harness.startContinuous.mockResolvedValue({ + status: "blocked", + reason: "capability-not-applicable", + }); + harness.replicateUnattended.mockResolvedValue({ + status: "blocked", + reason: "capability-not-implemented", + }); + + await harness.resume(); + + expect(harness.startContinuous).toHaveBeenCalledOnce(); + expect(harness.replicateUnattended).toHaveBeenCalledWith({ + trigger: "resume", + interaction: NO_INTERACTION, + }); + }); +}); diff --git a/src/modules/core/ReplicationScheduling.ts b/src/modules/core/ReplicationScheduling.ts new file mode 100644 index 00000000..21fc48ac --- /dev/null +++ b/src/modules/core/ReplicationScheduling.ts @@ -0,0 +1,49 @@ +/** + * Host-owned scheduling state shared by the lifecycle coordinator and the + * CLI daemon. It deliberately contains policy state only; provider choice and + * replication execution remain in ReplicationService. + */ +export interface ReplicationSchedulingControl { + /** The daemon owns recurring polling and suppresses host automation. */ + externalPolling: boolean; + /** A Continuous start is pending or accepted and therefore owns recurring synchronisation. */ + continuousOwnsRecurring: boolean; + /** The daemon's initial convergence satisfies the next resume OneShot. */ + initialOneShotSatisfied: boolean; + /** Registered by the periodic module so the daemon can remove an old timer. */ + disablePeriodic?: () => void; + /** Reconcile the periodic timer after recurring ownership changes. */ + refreshPeriodic?: () => void; +} + +const controls = new WeakMap(); + +export function getReplicationSchedulingControl(owner: object): ReplicationSchedulingControl { + let control = controls.get(owner); + if (!control) { + control = { + externalPolling: false, + continuousOwnsRecurring: false, + initialOneShotSatisfied: false, + }; + controls.set(owner, control); + } + return control; +} + +export function setContinuousSchedulingOwnership(owner: object, ownsRecurring: boolean): void { + const control = getReplicationSchedulingControl(owner); + if (control.continuousOwnsRecurring === ownsRecurring) return; + control.continuousOwnsRecurring = ownsRecurring; + control.refreshPeriodic?.(); +} + +export function setExternalPollingMode(owner: object, enabled: boolean): void { + const control = getReplicationSchedulingControl(owner); + control.externalPolling = enabled; + if (enabled) control.disablePeriodic?.(); +} + +export function markInitialOneShotSatisfied(owner: object): void { + getReplicationSchedulingControl(owner).initialOneShotSatisfied = true; +} diff --git a/src/modules/coreFeatures/ModuleConflictResolver.ts b/src/modules/coreFeatures/ModuleConflictResolver.ts index 85d060a1..0e952dd0 100644 --- a/src/modules/coreFeatures/ModuleConflictResolver.ts +++ b/src/modules/coreFeatures/ModuleConflictResolver.ts @@ -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); diff --git a/src/modules/coreFeatures/ModuleConflictResolver.unit.spec.ts b/src/modules/coreFeatures/ModuleConflictResolver.unit.spec.ts index 18bfb618..630d298e 100644 --- a/src/modules/coreFeatures/ModuleConflictResolver.unit.spec.ts +++ b/src/modules/coreFeatures/ModuleConflictResolver.unit.spec.ts @@ -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), diff --git a/src/modules/essential/ModuleBasicMenu.ts b/src/modules/essential/ModuleBasicMenu.ts index 06d27450..5bedf7b6 100644 --- a/src/modules/essential/ModuleBasicMenu.ts +++ b/src/modules/essential/ModuleBasicMenu.ts @@ -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({ diff --git a/src/modules/essential/ModuleBasicMenu.unit.spec.ts b/src/modules/essential/ModuleBasicMenu.unit.spec.ts index af5871d3..7f670aa0 100644 --- a/src/modules/essential/ModuleBasicMenu.unit.spec.ts +++ b/src/modules/essential/ModuleBasicMenu.unit.spec.ts @@ -25,7 +25,7 @@ function createFixture() { registerProtocolHandler: vi.fn(), }, replication: { - replicate: vi.fn(async () => undefined), + replicateUserInitiated: vi.fn(async () => ({ status: "completed" as const })), }, vault: { getActiveFilePath: vi.fn((): string | null => "note.md"), diff --git a/src/modules/essentialObsidian/ModuleObsidianEvents.ts b/src/modules/essentialObsidian/ModuleObsidianEvents.ts index d266dd72..5f689bc6 100644 --- a/src/modules/essentialObsidian/ModuleObsidianEvents.ts +++ b/src/modules/essentialObsidian/ModuleObsidianEvents.ts @@ -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); } diff --git a/src/modules/essentialObsidian/ModuleObsidianMenu.ts b/src/modules/essentialObsidian/ModuleObsidianMenu.ts index 911d5c6e..f11d8b9b 100644 --- a/src/modules/essentialObsidian/ModuleObsidianMenu.ts +++ b/src/modules/essentialObsidian/ModuleObsidianMenu.ts @@ -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 { @@ -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); diff --git a/src/modules/features/ModuleInteractiveConflictResolver.ts b/src/modules/features/ModuleInteractiveConflictResolver.ts index 089aa625..27e188ee 100644 --- a/src/modules/features/ModuleInteractiveConflictResolver.ts +++ b/src/modules/features/ModuleInteractiveConflictResolver.ts @@ -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(); @@ -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); diff --git a/src/modules/features/ModuleInteractiveConflictResolver.unit.spec.ts b/src/modules/features/ModuleInteractiveConflictResolver.unit.spec.ts index 82c2a8ef..b3a46f0d 100644 --- a/src/modules/features/ModuleInteractiveConflictResolver.unit.spec.ts +++ b/src/modules/features/ModuleInteractiveConflictResolver.unit.spec.ts @@ -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) }, }; From 8e5b058eefc837b0c583e96cdbe527246a46d1e4 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 27 Aug 2026 13:18:17 +0000 Subject: [PATCH 03/43] Define P2P session cancellation ownership --- ...eplicator_capabilities_01_core_contract.md | 27 ++++++--- ...r_capabilities_02_p2p_service_lifecycle.md | 59 ++++++++++++++----- ...plicator_capabilities_03_migration_plan.md | 26 ++++++-- 3 files changed, 84 insertions(+), 28 deletions(-) diff --git a/docs/adr/2026_08_replicator_capabilities_01_core_contract.md b/docs/adr/2026_08_replicator_capabilities_01_core_contract.md index 1bf66f34..e20d2a83 100644 --- a/docs/adr/2026_08_replicator_capabilities_01_core_contract.md +++ b/docs/adr/2026_08_replicator_capabilities_01_core_contract.md @@ -331,6 +331,12 @@ initialisation, reset, lock, unlock, and resolution settle only after their defined remote write succeeds; a Rebuilder must not continue after an ignored mutation failure. +`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. + ### Distinguish observation from an identity result only when required Capability availability and operation results are separate. A supported @@ -367,17 +373,22 @@ active -> retiring -> disposed -> replacement published ``` Acquisitions wait for the transition and receive only the replacement. A fenced -old handle cannot start work. Disposal stops Continuous activity, awaits work -which cannot be cancelled, and settles or reports every operation owned by the -active adapter before publication. If bounded retirement cannot settle, -replacement fails visibly and no new handle is published; the old handle is -disposed when late work settles. +old handle cannot start work. Disposal stops Continuous activity, requests +cancellation of work which supports it, awaits work which cannot be cancelled, +and settles or reports every operation owned by the active adapter before +publication. If bounded retirement cannot settle, replacement fails visibly +and no new handle is published; the old handle is disposed when late work +settles. 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 declares this role `not-implemented` until its lower -level transfer and RPC work have a real stop path. +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. `getNewReplicator()` is not a general temporary-instance API. Setup and settings flows request narrow probes, such as connection, preferred-tweak, or isolated @@ -426,7 +437,7 @@ support states. | Remote storage status | S | S | NA | | Compromised-Chunk inspection | S | NA | NA | | Central-remote Security Seed | S | S | NA | -| Request to stop active transfer | S | S | NI | +| Request to stop active transfer | S | S | S | P2P unattended OneShot means a role exists which uses configured target names without opening a dialogue. Peer-room, watch, acceptance, and broadcast roles diff --git a/docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md b/docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md index 26d900b5..735e9343 100644 --- a/docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md +++ b/docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md @@ -65,7 +65,8 @@ 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 and cancellation signal; +- 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 @@ -147,11 +148,31 @@ 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 its own internal, -session-epoch-bound demand. 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. Demand bookkeeping is -internal to the service and is not a general consumer contract; it cannot turn -a finite transfer into persistent transport policy. +session-epoch-bound demand and operation controller. The operation consumes an +effective signal composed from the room session, its operation controller, and +any narrower caller or incoming-RPC signal. The room-session owner, rather than +the adapter or UI consumer, owns every controller and operation settlement. + +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 @@ -173,19 +194,18 @@ client, or policy demand remains reachable after replacement. Reconciliation is serialised with room lifecycle operations: 1. fence new session work; -2. allow already-started finite transfers to settle, because the current lower - level transfer may not yet have a real cancellation contract; +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 silently interrupts -a transfer without a cancellation contract. 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. +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 @@ -258,6 +278,15 @@ host must reject concurrent probing while the active service uses those sockets. It must not pause or close active relay sockets as a side effect of a short-lived check. +The probe owns the same kind of lifetime controller as a room session, and +`dispose()` aborts and settles its work before releasing its own room. This +makes probe retirement bounded, but it does not turn process-global Trystero +relay state into isolated state. Runtime acquisition therefore reuses or +observes a compatible active session where the probe requires no second room, +allows a separately owned logical room only when the effective relay binding is +compatible, and otherwise returns a blocked result. It does not silently retire +the active service to make an incompatible probe possible. + ### Keep provider composition explicit The stable P2P service can be composed even when P2P is not the selected main diff --git a/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md b/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md index bc5eb129..0712741b 100644 --- a/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md +++ b/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md @@ -116,10 +116,14 @@ 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. Keep -the P2P stop role `not-implemented` until a lower-level operation exists. Add -internal session-demand ownership for finite operations and policy-held -AutoStart without exposing that bookkeeping as a general consumer API. +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: @@ -132,7 +136,19 @@ Add ownership regressions immediately before implementation: advertisements do not; - local database replacement retires database-bound feeds and publication; - repeated replacement does not retain platform-event subscriptions; -- policy-only change waits for an already-started finite transfer to settle; +- 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; and - database replacement fences both active-provider and P2P work before From 7cf4ec49eda43e12b3dc98167b4e41619a6c984b Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 27 Aug 2026 13:18:46 +0000 Subject: [PATCH 04/43] Route transfer stops through provider capabilities --- src/LiveSyncBaseCore.ts | 3 +++ src/modules/essential/ModuleBasicMenu.ts | 2 +- src/modules/essential/ModuleBasicMenu.unit.spec.ts | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/LiveSyncBaseCore.ts b/src/LiveSyncBaseCore.ts index 76f06006..27df933d 100644 --- a/src/LiveSyncBaseCore.ts +++ b/src/LiveSyncBaseCore.ts @@ -41,6 +41,7 @@ import { supportedOpenReplicationContinuous, supportedOpenReplicationOneShot, supportedOpenReplicationUnattended, + supportedStopActiveTransfer, } from "@vrtmrz/livesync-commonlib/replication"; import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator"; @@ -165,6 +166,7 @@ export class LiveSyncBaseCore< userInitiatedOneShot: supportedOpenReplicationOneShot(), unattendedOneShot: supportedOpenReplicationUnattended(), continuous: supportedOpenReplicationContinuous(), + stopActiveTransfer: supportedStopActiveTransfer(), }, [REMOTE_MINIO]: { kind: REMOTE_MINIO, @@ -175,6 +177,7 @@ export class LiveSyncBaseCore< userInitiatedOneShot: supportedOpenReplicationOneShot(), unattendedOneShot: supportedOpenReplicationUnattended(), continuous: CAPABILITY_NOT_APPLICABLE, + stopActiveTransfer: supportedStopActiveTransfer(), }, }); this.services.replicator.registerReplicatorProviderDefinitions(definitions); diff --git a/src/modules/essential/ModuleBasicMenu.ts b/src/modules/essential/ModuleBasicMenu.ts index 5bedf7b6..7bab5ef9 100644 --- a/src/modules/essential/ModuleBasicMenu.ts +++ b/src/modules/essential/ModuleBasicMenu.ts @@ -89,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; }, diff --git a/src/modules/essential/ModuleBasicMenu.unit.spec.ts b/src/modules/essential/ModuleBasicMenu.unit.spec.ts index 7f670aa0..e0c13bbf 100644 --- a/src/modules/essential/ModuleBasicMenu.unit.spec.ts +++ b/src/modules/essential/ModuleBasicMenu.unit.spec.ts @@ -26,6 +26,7 @@ function createFixture() { }, replication: { 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(); From be03c25904e864b86c0aa92fcd7cb87f1d7728de Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 27 Aug 2026 13:52:10 +0000 Subject: [PATCH 05/43] Remove obsolete P2P composition comment --- src/main.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main.ts b/src/main.ts index cb68bd79..4dd779c6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -201,10 +201,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin { ); waitForCompatibilityReview = () => compatibilityReview.openReview(); useReviewHarness(core, this, replicator, compatibilityReview); - // p2pReplicatorResult = useP2PReplicator(core, [ - // VIEW_TYPE_P2P, - // (leaf: any) => new P2PReplicatorPaneView(leaf, core, p2pReplicatorResult!), - // ]); } ); } From 4dcc783a7141a49b4be88c62278452dd8f3bff03 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 27 Aug 2026 15:44:04 +0000 Subject: [PATCH 06/43] Use stable P2P service views in the CLI --- src/apps/cli/commands/p2p.ts | 113 +++++++--------- src/apps/cli/commands/p2p.unit.spec.ts | 123 +++++++++++++++++- src/apps/cli/commands/runCommand.ts | 6 +- src/apps/cli/commands/types.ts | 2 +- src/apps/cli/main.ts | 13 +- .../p2p-replicator-replacement.ts | 79 ++++++----- .../test-p2p-replicator-replacement.ts | 9 +- src/main.ts | 3 +- 8 files changed, 230 insertions(+), 118 deletions(-) diff --git a/src/apps/cli/commands/p2p.ts b/src/apps/cli/commands/p2p.ts index c504ae01..f6d9f0e6 100644 --- a/src/apps/cli/commands/p2p.ts +++ b/src/apps/cli/commands/p2p.ts @@ -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,12 +11,7 @@ type CLIP2PPeer = { name: string; }; -type CandidateSummary = { - id: string; - candidateType: string; - protocol: string; - relayProtocol: string; -}; +type CLIP2PService = Pick; function delay(ms: number): Promise { return new Promise((resolve) => compatGlobal.setTimeout(resolve, ms)); @@ -43,35 +37,35 @@ function validateP2PSettings(core: LiveSyncBaseCore) { settings.P2P_IsHeadless = true; } -async function createReplicator(core: LiveSyncBaseCore): Promise { +function requireP2PService( + core: LiveSyncBaseCore, + 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): CLIP2PPeer[] { + return [...service.peerDirectory.getPeers()] .map((peer) => ({ peerId: peer.peerId, name: peer.name })) .sort((a, b) => a.peerId.localeCompare(b.peerId)); } export async function collectPeers( core: LiveSyncBaseCore, + p2pService: CLIP2PService | undefined, timeoutSec: number ): Promise { - 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 +84,8 @@ function resolvePeer(peers: CLIP2PPeer[], peerToken: string): CLIP2PPeer | undef return undefined; } -function getReportValue( - report: Record | 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).find((r) => r.id === candidateId); - if (!report) { - return undefined; - } - return { - id: candidateId, - candidateType: getReportValue(report, "candidateType"), - protocol: getReportValue(report, "protocol"), - relayProtocol: getReportValue(report, "relayProtocol"), - }; -} - async function writePeerConnectionStatsIfRequested( - replicator: LiveSyncTrysteroReplicator, + service: Pick, peer: CLIP2PPeer ): Promise { const outputPath = process.env.LIVESYNC_P2P_STATS_JSONL?.trim(); @@ -123,21 +93,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 +134,24 @@ async function writePeerConnectionStatsIfRequested( localCandidate, remoteCandidate, }; - await fsPromises.appendFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8"); + return payload; } export async function syncWithPeer( core: LiveSyncBaseCore, + p2pService: CLIP2PService | undefined, peerToken: string, timeoutSec: number ): Promise { - 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 +163,11 @@ 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); + 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 +175,18 @@ 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): Promise { - const replicator = await createReplicator(core); - await replicator.open(); - return replicator; +export async function openP2PHost( + core: LiveSyncBaseCore, + p2pService: CLIP2PService | undefined +): Promise { + const service = requireP2PService(core, p2pService); + await service.transportLifecycle.connect(); + return service; } diff --git a/src/apps/cli/commands/p2p.unit.spec.ts b/src/apps/cli/commands/p2p.unit.spec.ts index d0c2342c..6cee45bc 100644 --- a/src/apps/cli/commands/p2p.unit.spec.ts +++ b/src/apps/cli/commands/p2p.unit.spec.ts @@ -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 () => ({ ok: true })); + const requestPushToPeer = vi.fn(async () => ({ ok: true })); + 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,88 @@ 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("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", + }, + }); + }); }); diff --git a/src/apps/cli/commands/runCommand.ts b/src/apps/cli/commands/runCommand.ts index eeb5bc51..2d4a4a68 100644 --- a/src/apps/cli/commands/runCommand.ts +++ b/src/apps/cli/commands/runCommand.ts @@ -252,7 +252,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"); } @@ -269,14 +269,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; diff --git a/src/apps/cli/commands/types.ts b/src/apps/cli/commands/types.ts index 7dfa8d80..3b87a51c 100644 --- a/src/apps/cli/commands/types.ts +++ b/src/apps/cli/commands/types.ts @@ -1,7 +1,7 @@ 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"; export type CLICommand = | "daemon" diff --git a/src/apps/cli/main.ts b/src/apps/cli/main.ts index 38b2b033..240d7694 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -23,8 +23,7 @@ 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 { createNodeStandardIo, fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node"; import type { StandardIo } from "@vrtmrz/livesync-commonlib/context"; import { writeStderrLine, writeStdoutLine } from "./cliOutput"; @@ -290,7 +289,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 +422,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); } diff --git a/src/apps/cli/test-support/p2p-replicator-replacement.ts b/src/apps/cli/test-support/p2p-replicator-replacement.ts index 18ab6bef..1f7f6447 100644 --- a/src/apps/cli/test-support/p2p-replicator-replacement.ts +++ b/src/apps/cli/test-support/p2p-replicator-replacement.ts @@ -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 { +type ProbeP2PService = Pick; + +async function waitForServing(service: ProbeP2PService, timeoutMs: number): Promise { 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; } diff --git a/src/apps/cli/testdeno/test-p2p-replicator-replacement.ts b/src/apps/cli/testdeno/test-p2p-replicator-replacement.ts index a6075535..231337e5 100644 --- a/src/apps/cli/testdeno/test-p2p-replicator-replacement.ts +++ b/src/apps/cli/testdeno/test-p2p-replicator-replacement.ts @@ -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, diff --git a/src/main.ts b/src/main.ts index 4dd779c6..5a0642d0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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"; From d24bda88be58f7552418c9c359f1a572e8fef106 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 27 Aug 2026 17:57:59 +0000 Subject: [PATCH 07/43] Dispose temporary P2P setup probes --- src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte index 13293053..82324b14 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte @@ -143,7 +143,7 @@ return ""; } finally { try { - await replicator.close(); + await replicator.dispose(); await dummyPouch.destroy(); } catch (e) { Logger(e, LOG_LEVEL_VERBOSE, "setup-p2p-cleanup"); From cc000f2cdfaee3c28268dbad388a54163bd447fb Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 27 Aug 2026 17:58:19 +0000 Subject: [PATCH 08/43] Clarify P2P session lifecycle contracts --- ...r_capabilities_02_p2p_service_lifecycle.md | 40 ++++++++++++++----- ...plicator_capabilities_03_migration_plan.md | 10 ++++- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md b/docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md index 735e9343..b7b184d4 100644 --- a/docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md +++ b/docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md @@ -73,10 +73,10 @@ validity depends on that membership: - 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 an internal fence for one room session. It is not a -public capability, a persisted profile identifier, or a synonym for the -logical room. Every callback, snapshot, and operation token carrying -session-bound state is checked against the current epoch. +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 @@ -110,11 +110,15 @@ 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. An operation -which carries an old epoch returns a stale or blocked result rather than -dispatching into a 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. +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 @@ -134,6 +138,11 @@ explicit disconnect veto. This is a lifecycle veto, not the opposite of 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. + ### Separate automation policy from room ownership P2P automation is a composed service feature. It owns `P2P_AutoStart`, @@ -191,6 +200,12 @@ while persisted peer decisions survive. AutoStart reconnects when it remains enabled 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; @@ -222,7 +237,12 @@ the P2P service: 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. +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 diff --git a/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md b/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md index 0712741b..fb36534d 100644 --- a/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md +++ b/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md @@ -134,7 +134,10 @@ Add ownership regressions immediately before implementation: 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; +- 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; @@ -150,7 +153,10 @@ Add ownership regressions immediately before implementation: - 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; and + 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. From ad83ae858d76886c5894a35d6d73f5977b8f2760 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 28 Aug 2026 03:04:53 +0000 Subject: [PATCH 09/43] Adopt focused P2P service views --- src/LiveSyncBaseCore.ts | 3 + src/apps/webpeer/src/P2PCheckSession.ts | 2 +- src/apps/webpeer/src/WebPeerRuntime.ts | 17 ++-- .../P2PReplicator/P2POpenReplicationModal.ts | 10 +-- .../P2POpenReplicationPane.svelte | 13 ++-- .../P2PSync/P2PReplicator/P2PReplicationUI.ts | 13 ++-- .../P2PReplicationUI.unit.spec.ts | 23 ++++-- .../P2PReplicator/P2PReplicatorPane.svelte | 12 ++- .../P2PReplicator/P2PReplicatorPaneHost.ts | 14 +++- .../P2PReplicator/P2PReplicatorPaneView.ts | 19 ++--- .../P2PReplicator/P2PServerStatusCard.svelte | 16 ++-- .../P2PReplicator/P2PServerStatusPane.svelte | 23 +++--- .../P2PReplicator/P2PServerStatusPaneView.ts | 10 +-- .../P2PReplicator/PeerStatusRow.svelte | 15 ++-- src/modules/core/ModuleReplicator.ts | 2 +- .../core/ModuleReplicator.unit.spec.ts | 77 ++++++++++++++++--- src/serviceFeatures/useP2PReplicatorUI.ts | 15 ++-- .../useP2PReplicatorUI.unit.spec.ts | 17 +++- .../apps/webpeer/P2PCheckSession.unit.spec.ts | 57 ++++++++++++++ test/apps/webpeer/WebPeerRuntime.unit.spec.ts | 30 +++++++- 20 files changed, 276 insertions(+), 112 deletions(-) create mode 100644 test/apps/webpeer/P2PCheckSession.unit.spec.ts diff --git a/src/LiveSyncBaseCore.ts b/src/LiveSyncBaseCore.ts index 27df933d..12168bec 100644 --- a/src/LiveSyncBaseCore.ts +++ b/src/LiveSyncBaseCore.ts @@ -37,6 +37,7 @@ import { usePrepareDatabaseForUse } from "@vrtmrz/livesync-commonlib/compat/serv import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils.type"; import { CAPABILITY_NOT_APPLICABLE, + CENTRAL_REMOTE_REPLICATION_READINESS, defineReplicatorProviderDefinitions, supportedOpenReplicationContinuous, supportedOpenReplicationOneShot, @@ -158,6 +159,7 @@ export class LiveSyncBaseCore< [REMOTE_COUCHDB]: { kind: REMOTE_COUCHDB, diagnosticName: "CouchDB", + readiness: CENTRAL_REMOTE_REPLICATION_READINESS, isConfigured: (settings) => settings.remoteType === REMOTE_COUCHDB && !!settings.couchDB_URI?.trim() && @@ -171,6 +173,7 @@ export class LiveSyncBaseCore< [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(), create: (_settings) => Promise.resolve(new LiveSyncJournalReplicator(this)), diff --git a/src/apps/webpeer/src/P2PCheckSession.ts b/src/apps/webpeer/src/P2PCheckSession.ts index 2c49569b..3ff3356a 100644 --- a/src/apps/webpeer/src/P2PCheckSession.ts +++ b/src/apps/webpeer/src/P2PCheckSession.ts @@ -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; diff --git a/src/apps/webpeer/src/WebPeerRuntime.ts b/src/apps/webpeer/src/WebPeerRuntime.ts index 4aa323bc..c6e7d433 100644 --- a/src/apps/webpeer/src/WebPeerRuntime.ts +++ b/src/apps/webpeer/src/WebPeerRuntime.ts @@ -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; - 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() diff --git a/src/features/P2PSync/P2PReplicator/P2POpenReplicationModal.ts b/src/features/P2PSync/P2PReplicator/P2POpenReplicationModal.ts index fba0d767..b3b527aa 100644 --- a/src/features/P2PSync/P2PReplicator/P2POpenReplicationModal.ts +++ b/src/features/P2PSync/P2PReplicator/P2POpenReplicationModal.ts @@ -1,7 +1,7 @@ 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"; export type P2POpenReplicationModalCallback = { onSync: (peerId: string) => Promise; @@ -9,7 +9,7 @@ export type P2POpenReplicationModalCallback = { }; export class P2POpenReplicationModal extends Modal { - liveSyncReplicator: LiveSyncTrysteroReplicator; + p2p: P2PServiceViews; callback?: P2POpenReplicationModalCallback; component?: ReturnType; showResult: boolean; @@ -19,7 +19,7 @@ export class P2POpenReplicationModal extends Modal { constructor( app: App, - liveSyncReplicator: LiveSyncTrysteroReplicator, + p2p: P2PServiceViews, callback?: P2POpenReplicationModalCallback, showResult: boolean = false, title: string = "P2P Replication", @@ -27,7 +27,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; @@ -57,7 +57,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(), diff --git a/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte b/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte index 64b0821b..618c20f9 100644 --- a/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte +++ b/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte @@ -9,13 +9,13 @@ // 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; + p2p: P2PServiceViews; onSync: (_peerId: string) => Promise; onSyncAndClose: (_peerId: string) => Promise; onClose: () => void; @@ -23,15 +23,14 @@ 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(undefined); let syncingPeerId = $state(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(() => { @@ -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 @@
- +

{translateMessage("Available Peers")}

diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts index 39cf4a62..65bc7c3e 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts +++ b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts @@ -2,6 +2,7 @@ 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"; /** @@ -15,8 +16,8 @@ import { P2POpenReplicationModal } from "./P2POpenReplicationModal"; */ export function createOpenReplicationUI( app: App -): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise { - return (replicator: LiveSyncTrysteroReplicator) => +): (replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) => (showResult: boolean) => Promise { + return (replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) => (showResult: boolean): Promise => { const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO; return new Promise((resolve) => { @@ -57,7 +58,7 @@ export function createOpenReplicationUI( }; const modal = new P2POpenReplicationModal( app, - replicator, + p2p, { onSync: (peerId: string) => synchronise(peerId, false), onSyncAndClose: (peerId: string) => synchronise(peerId, true), @@ -85,8 +86,8 @@ export function createOpenReplicationUI( */ export function createOpenRebuildUI( app: App -): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise { - return (replicator: LiveSyncTrysteroReplicator) => +): (replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) => (showResult: boolean) => Promise { + return (replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) => (showResult: boolean): Promise => { const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO; return new Promise((resolve) => { @@ -132,7 +133,7 @@ export function createOpenRebuildUI( const modal = new P2POpenReplicationModal( app, - replicator, + p2p, { onSync: doRebuild, onSyncAndClose: doRebuild, diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.unit.spec.ts b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.unit.spec.ts index 86ad37e0..252e8255 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.unit.spec.ts +++ b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.unit.spec.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const modalState = vi.hoisted(() => ({ instances: [] as Array<{ + p2p: unknown; callback: { onSync: (peerId: string) => Promise; onSyncAndClose: (peerId: string) => Promise; @@ -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); @@ -46,15 +49,21 @@ function createReplicator() { } as any; } +function createP2PServiceViews() { + return { transportLifecycle: {}, 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)(createReplicator(), p2p)(true); const modal = modalState.instances[0]; + expect(modal.p2p).toBe(p2p); expect(modal.onClosed).toBeTypeOf("function"); modal.onClosed?.(); @@ -63,7 +72,7 @@ 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 session = createOpenReplicationUI({} as any)(replicator, createP2PServiceViews())(true); const modal = modalState.instances[0]; let settled = false; void session.finally(() => { @@ -91,7 +100,7 @@ describe("createOpenReplicationUI", () => { finishPull = resolve; }) ); - const session = createOpenReplicationUI({} as any)(replicator)(true); + const session = createOpenReplicationUI({} as any)(replicator, createP2PServiceViews())(true); const modal = modalState.instances[0]; let settled = false; void session.finally(() => { @@ -111,7 +120,7 @@ describe("createOpenReplicationUI", () => { it("closes the P2P connection after a successful sync-and-close action", async () => { const replicator = createReplicator(); - const session = createOpenReplicationUI({} as any)(replicator)(true); + const session = createOpenReplicationUI({} as any)(replicator, createP2PServiceViews())(true); const modal = modalState.instances[0]; await modal.callback.onSyncAndClose("peer-a"); @@ -143,7 +152,7 @@ describe("createOpenRebuildUI", () => { 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(() => { @@ -166,7 +175,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?.(); diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte b/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte index a059c2b0..f8bf13ab 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte +++ b/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte @@ -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(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`; diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost.ts b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost.ts index 0118aa4b..3204e2dd 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost.ts +++ b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost.ts @@ -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; +/** + * 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; } diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts index 99671f81..2b9e1d84 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts +++ b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts @@ -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), }, }, diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte b/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte index caa5cd42..13e7a244 100644 --- a/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte @@ -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(undefined); let replicatorStatus = $state(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(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(); } } diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte b/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte index 3171cf72..09530814 100644 --- a/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte @@ -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"; @@ -33,11 +33,11 @@ } from "./p2pPeerSettings"; interface Props { - getLiveSyncReplicator: () => LiveSyncTrysteroReplicator; + p2p: P2PServiceViews; core: LiveSyncBaseCore; } - let { getLiveSyncReplicator, core }: Props = $props(); + let { p2p, core }: Props = $props(); let serverInfo = $state(undefined); let replicatorInfo = $state(undefined); let decidingPeerId = $state(null); @@ -121,7 +121,7 @@ } async function requestServerStatus() { - await getLiveSyncReplicator().requestStatus(); + p2p.diagnostics.requestStatus(); eventHub.emitEvent(EVENT_REQUEST_STATUS); } @@ -296,7 +296,7 @@ ) { decidingPeerId = peer.peerId; try { - await getLiveSyncReplicator().makeDecision({ + await p2p.peerAdmission.makeDecision({ peerId: peer.peerId, name: peer.name, decision, @@ -311,7 +311,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 +324,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 +344,9 @@ return; } if (isWatching(peerId)) { - getLiveSyncReplicator().unwatchPeer(peerId); + p2p.changeRelay.unwatchPeer(peerId); } else { - getLiveSyncReplicator().watchPeer(peerId); + p2p.changeRelay.watchPeer(peerId); } } @@ -455,7 +452,7 @@

{/if} - +
diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts b/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts index e10cfad9..0a5eed05 100644 --- a/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts @@ -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, }, }); diff --git a/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte b/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte index 1ad8bb68..d9f58a0b 100644 --- a/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte +++ b/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte @@ -1,17 +1,16 @@